Enhance Tender Management with Company-Based Matching and API Updates

- Updated the tender service to include company-based matching for tenders, allowing for more relevant results based on company CPV codes.
- Modified the ListTenders endpoint to accept a company ID parameter for calculating match percentages and sorting tenders accordingly.
- Refactored the tender response structure to include match percentage and days until deadline for better client-side handling.
- Updated API documentation to reflect changes in the tender listing functionality and added new endpoints for public tender access.
- Removed deprecated customer-related methods and streamlined customer listing functionality for improved clarity and performance.
This commit is contained in:
n.nakhostin
2025-08-13 19:47:58 +03:30
parent 96c21b8b78
commit 0a23ff985a
13 changed files with 4543 additions and 668 deletions
+1630 -168
View File
File diff suppressed because it is too large Load Diff
+1630 -168
View File
File diff suppressed because it is too large Load Diff
+1048 -115
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -110,7 +110,7 @@ func main() {
userService := user.NewUserService(userRepository, logger, userAuthService, userValidator) userService := user.NewUserService(userRepository, logger, userAuthService, userValidator)
companyService := company.NewCompanyService(companyRepository, logger) companyService := company.NewCompanyService(companyRepository, logger)
customerService := customer.NewCustomerService(customerRepository, logger, customerAuthService, companyService) customerService := customer.NewCustomerService(customerRepository, logger, customerAuthService, companyService)
tenderService := tender.NewTenderService(tenderRepository, logger) tenderService := tender.NewTenderService(tenderRepository, companyService, logger)
logger.Info("Services initialized successfully", map[string]interface{}{ logger.Info("Services initialized successfully", map[string]interface{}{
"services": []string{"customer", "user", "company", "tender"}, "services": []string{"customer", "user", "company", "tender"},
+1 -1
View File
@@ -68,7 +68,6 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
} }
// Admin Customers Routes // Admin Customers Routes
customersGP := adminV1.Group("/customers") customersGP := adminV1.Group("/customers")
{ {
customersGP.Use(userHandler.AuthMiddleware()) customersGP.Use(userHandler.AuthMiddleware())
@@ -130,6 +129,7 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende
// Public Tenders Routes // Public Tenders Routes
tendersGP := v1.Group("/tenders") tendersGP := v1.Group("/tenders")
tendersGP.Use(customerHandler.AuthMiddleware())
{ {
tendersGP.GET("", tenderHandler.GetPublicTenders) tendersGP.GET("", tenderHandler.GetPublicTenders)
tendersGP.GET("/:id", tenderHandler.GetPublicTenderDetails) tendersGP.GET("/:id", tenderHandler.GetPublicTenderDetails)
+1 -40
View File
@@ -207,7 +207,7 @@ func (h *Handler) ListCustomers(c echo.Context) error {
return response.ValidationError(c, "Invalid request data", err.Error()) return response.ValidationError(c, "Invalid request data", err.Error())
} }
result, err := h.service.ListCustomersWithCompanies(c.Request().Context(), form) result, err := h.service.ListCustomers(c.Request().Context(), form)
if err != nil { if err != nil {
return response.InternalServerError(c, "Failed to list customers") return response.InternalServerError(c, "Failed to list customers")
} }
@@ -215,45 +215,6 @@ func (h *Handler) ListCustomers(c echo.Context) error {
return response.Success(c, result, "Customers retrieved successfully") return response.Success(c, result, "Customers retrieved successfully")
} }
// ListCustomersWithCompanies lists customers with companies and filters
// @Summary List customers with companies and filters
// @Description Retrieve a paginated list of customers with their assigned companies and advanced filtering options including search, type, status, company, industry, verification status, and compliance status. Supports sorting and pagination.
// @Tags Admin-Customers
// @Accept json
// @Produce json
// @Param search query string false "Search term to filter customers by name, email, or company name"
// @Param type query string false "Filter by customer type" Enums(individual, company, government)
// @Param status query string false "Filter by customer status" Enums(active, inactive, suspended, pending)
// @Param company_id query string false "Filter by company ID" format(uuid)
// @Param industry query string false "Filter by industry"
// @Param is_verified query boolean false "Filter by verification status"
// @Param is_compliant query boolean false "Filter by compliance status"
// @Param language query string false "Filter by preferred language"
// @Param currency query string false "Filter by preferred currency"
// @Param limit query integer false "Number of customers per page (1-100)" minimum(1) maximum(100) default(20)
// @Param offset query integer false "Number of customers to skip for pagination" minimum(0) default(0)
// @Param sort_by query string false "Field to sort by" Enums(email, company_name, created_at, updated_at, status) default(created_at)
// @Param sort_order query string false "Sort order" Enums(asc, desc) default(desc)
// @Success 200 {object} response.APIResponse{data=CustomerListResponse} "Customers with companies retrieved successfully"
// @Failure 400 {object} response.APIResponse "Bad request - Invalid query parameters"
// @Failure 422 {object} response.APIResponse "Validation error - Invalid query parameters"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Security BearerAuth
// @Router /admin/v1/customers/with-companies [get]
func (h *Handler) ListCustomersWithCompanies(c echo.Context) error {
form, err := response.Parse[ListCustomersForm](c)
if err != nil {
return response.ValidationError(c, "Invalid request data", err.Error())
}
result, err := h.service.ListCustomersWithCompanies(c.Request().Context(), form)
if err != nil {
return response.InternalServerError(c, "Failed to list customers with companies")
}
return response.Success(c, result, "Customers with companies retrieved successfully")
}
// UpdateCustomerStatus updates customer status (Web Panel) // UpdateCustomerStatus updates customer status (Web Panel)
// @Summary Update customer status // @Summary Update customer status
// @Description Update customer account status (active, inactive, suspended, pending) // @Description Update customer account status (active, inactive, suspended, pending)
-65
View File
@@ -20,13 +20,11 @@ type Repository interface {
GetByRegistrationNumber(ctx context.Context, registrationNumber string) (*Customer, error) GetByRegistrationNumber(ctx context.Context, registrationNumber string) (*Customer, error)
GetByTaxID(ctx context.Context, taxID string) (*Customer, error) GetByTaxID(ctx context.Context, taxID string) (*Customer, error)
GetByCompanyID(ctx context.Context, companyID string, limit, offset int) ([]*Customer, error) GetByCompanyID(ctx context.Context, companyID string, limit, offset int) ([]*Customer, error)
GetByCompanyIDs(ctx context.Context, companyIDs []string, limit, offset int) ([]*Customer, error)
Update(ctx context.Context, customer *Customer) error Update(ctx context.Context, customer *Customer) error
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
List(ctx context.Context, limit, offset int) ([]*Customer, error) List(ctx context.Context, limit, offset int) ([]*Customer, error)
Search(ctx context.Context, search string, customerType *string, status *string, companyID *string, industry *string, isVerified *bool, isCompliant *bool, language *string, currency *string, limit, offset int, sortBy, sortOrder string) ([]*Customer, error) Search(ctx context.Context, search string, customerType *string, status *string, companyID *string, industry *string, isVerified *bool, isCompliant *bool, language *string, currency *string, limit, offset int, sortBy, sortOrder string) ([]*Customer, error)
CountByCompanyID(ctx context.Context, companyID string) (int64, error) CountByCompanyID(ctx context.Context, companyID string) (int64, error)
CountByCompanyIDs(ctx context.Context, companyIDs []string) (int64, error)
CountByType(ctx context.Context, customerType CustomerType) (int64, error) CountByType(ctx context.Context, customerType CustomerType) (int64, error)
CountByStatus(ctx context.Context, status CustomerStatus) (int64, error) CountByStatus(ctx context.Context, status CustomerStatus) (int64, error)
UpdateStatus(ctx context.Context, id string, status CustomerStatus) error UpdateStatus(ctx context.Context, id string, status CustomerStatus) error
@@ -247,46 +245,6 @@ func (r *customerRepository) GetByCompanyID(ctx context.Context, companyID strin
return customers, nil return customers, nil
} }
// GetByCompanyIDs retrieves customers by multiple company IDs with pagination
func (r *customerRepository) GetByCompanyIDs(ctx context.Context, companyIDs []string, limit, offset int) ([]*Customer, error) {
if len(companyIDs) == 0 {
return nil, errors.New("no company IDs provided")
}
// Build pagination
pagination := mongopkg.NewPaginationBuilder().
Limit(limit).
Skip(offset).
SortDesc("created_at").
Build()
// Filter by company IDs and active status
filter := bson.M{
"company_ids": bson.M{"$in": companyIDs},
"status": bson.M{"$ne": CustomerStatusInactive},
}
// Use ORM to find customers
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get customers by multiple company IDs", map[string]interface{}{
"error": err.Error(),
"company_ids": companyIDs,
"limit": limit,
"offset": offset,
})
return nil, err
}
// Convert []Customer to []*Customer
customers := make([]*Customer, len(result.Items))
for i := range result.Items {
customers[i] = &result.Items[i]
}
return customers, nil
}
// Update updates a customer // Update updates a customer
func (r *customerRepository) Update(ctx context.Context, customer *Customer) error { func (r *customerRepository) Update(ctx context.Context, customer *Customer) error {
// Set updated timestamp using Unix timestamp // Set updated timestamp using Unix timestamp
@@ -466,29 +424,6 @@ func (r *customerRepository) CountByCompanyID(ctx context.Context, companyID str
return count, nil return count, nil
} }
// CountByCompanyIDs counts customers by multiple company IDs
func (r *customerRepository) CountByCompanyIDs(ctx context.Context, companyIDs []string) (int64, error) {
if len(companyIDs) == 0 {
return 0, errors.New("no company IDs provided")
}
filter := bson.M{
"company_ids": bson.M{"$in": companyIDs},
"status": bson.M{"$ne": CustomerStatusInactive},
}
count, err := r.ormRepo.Count(ctx, filter)
if err != nil {
r.logger.Error("Failed to count customers by multiple company IDs", map[string]interface{}{
"error": err.Error(),
"company_ids": companyIDs,
})
return 0, err
}
return count, nil
}
// CountByType counts customers by type // CountByType counts customers by type
func (r *customerRepository) CountByType(ctx context.Context, customerType CustomerType) (int64, error) { func (r *customerRepository) CountByType(ctx context.Context, customerType CustomerType) (int64, error) {
filter := bson.M{ filter := bson.M{
-74
View File
@@ -25,7 +25,6 @@ type Service interface {
// Customer listing and search // Customer listing and search
ListCustomers(ctx context.Context, form *ListCustomersForm) (*CustomerListResponse, error) ListCustomers(ctx context.Context, form *ListCustomersForm) (*CustomerListResponse, error)
ListCustomersWithCompanies(ctx context.Context, form *ListCustomersForm) (*CustomerListResponse, error)
GetCustomersByCompanyID(ctx context.Context, companyID string, limit, offset int) ([]*Customer, int64, error) GetCustomersByCompanyID(ctx context.Context, companyID string, limit, offset int) ([]*Customer, int64, error)
GetCustomersByType(ctx context.Context, customerType CustomerType, limit, offset int) ([]*Customer, int64, error) GetCustomersByType(ctx context.Context, customerType CustomerType, limit, offset int) ([]*Customer, int64, error)
GetCustomersByStatus(ctx context.Context, status CustomerStatus, limit, offset int) ([]*Customer, int64, error) GetCustomersByStatus(ctx context.Context, status CustomerStatus, limit, offset int) ([]*Customer, int64, error)
@@ -471,79 +470,6 @@ func (s *customerService) ListCustomers(ctx context.Context, form *ListCustomers
}, nil }, nil
} }
// ListCustomersWithCompanies lists customers with search and filters, including companies
func (s *customerService) ListCustomersWithCompanies(ctx context.Context, form *ListCustomersForm) (*CustomerListResponse, error) {
// Set defaults
limit := 20
if form.Limit != nil {
limit = *form.Limit
}
offset := 0
if form.Offset != nil {
offset = *form.Offset
}
search := ""
if form.Search != nil {
search = *form.Search
}
sortBy := "created_at"
if form.SortBy != nil {
sortBy = *form.SortBy
}
sortOrder := "desc"
if form.SortOrder != nil {
sortOrder = *form.SortOrder
}
// Parse company ID if provided
var companyID *string
if form.CompanyID != nil {
companyID = form.CompanyID
}
// Get customers
customers, err := s.repository.Search(ctx, search, form.Type, form.Status, companyID, form.Industry, form.IsVerified, form.IsCompliant, form.Language, form.Currency, limit, offset, sortBy, sortOrder)
if err != nil {
s.logger.Error("Failed to list customers with companies", map[string]interface{}{
"error": err.Error(),
})
return nil, errors.New("failed to list customers with companies")
}
// Convert to responses
var customerResponses []*CustomerResponse
for _, customer := range customers {
// Load companies for the customer
companies, err := s.loadCompaniesForCustomer(ctx, customer)
if err != nil {
s.logger.Error("Failed to load companies for customer in list", map[string]interface{}{
"error": err.Error(),
"customer_id": customer.ID,
})
// Continue without companies if loading fails
companies = []*CompanySummary{}
}
customerResponse := customer.ToResponseWithCompanies(companies)
customerResponses = append(customerResponses, customerResponse)
}
// TODO: Implement proper count for pagination metadata
total := int64(len(customers)) // This should be a separate count query
totalPages := (total + int64(limit) - 1) / int64(limit)
return &CustomerListResponse{
Customers: customerResponses,
Total: total,
Limit: limit,
Offset: offset,
TotalPages: int(totalPages),
}, nil
}
// GetCustomersByCompanyID retrieves customers by company ID with pagination // GetCustomersByCompanyID retrieves customers by company ID with pagination
func (s *customerService) GetCustomersByCompanyID(ctx context.Context, companyID string, limit, offset int) ([]*Customer, int64, error) { func (s *customerService) GetCustomersByCompanyID(ctx context.Context, companyID string, limit, offset int) ([]*Customer, int64, error) {
customers, err := s.repository.GetByCompanyID(ctx, companyID, limit, offset) customers, err := s.repository.GetByCompanyID(ctx, companyID, limit, offset)
+59
View File
@@ -416,3 +416,62 @@ func (s *ScrapingState) MarkAsCompleted(year, number int) {
s.NextRunAt = time.Now().Add(4 * time.Hour).Unix() // Schedule next run in 4 hours s.NextRunAt = time.Now().Add(4 * time.Hour).Unix() // Schedule next run in 4 hours
s.UpdatedAt = time.Now().Unix() s.UpdatedAt = time.Now().Unix()
} }
// CalculateMatchPercentage calculates the match percentage between tender classifications and company CPV codes
func (t *Tender) CalculateMatchPercentage(companyCPVCodes []string) int {
if len(companyCPVCodes) == 0 {
return 0
}
mainMatch := false
additionalMatch := false
// Check if main classification matches any company CPV codes
if t.MainClassification != "" {
for _, companyCode := range companyCPVCodes {
if companyCode == t.MainClassification {
mainMatch = true
break
}
}
}
// Check if any additional classification matches any company CPV codes
if len(t.AdditionalClassifications) > 0 {
for _, additionalCode := range t.AdditionalClassifications {
for _, companyCode := range companyCPVCodes {
if companyCode == additionalCode {
additionalMatch = true
break
}
}
if additionalMatch {
break
}
}
}
// Calculate percentage based on matches
if mainMatch && additionalMatch {
return 100
} else if mainMatch || additionalMatch {
return 50
}
return 0
}
// GetDaysUntilDeadline returns the number of days until the tender deadline
func (t *Tender) GetDaysUntilDeadline() int {
if t.TenderDeadline == 0 {
return 0
}
now := time.Now().UnixMilli()
if t.TenderDeadline <= now {
return 0 // Already expired
}
diffMillis := t.TenderDeadline - now
diffDays := diffMillis / (24 * 60 * 60 * 1000)
return int(diffDays)
}
+23 -8
View File
@@ -53,17 +53,18 @@ type UpdateTenderRequest struct {
// ListTendersRequest represents a request to list tenders with pagination and filtering // ListTendersRequest represents a request to list tenders with pagination and filtering
type ListTendersRequest struct { type ListTendersRequest struct {
Criteria TenderSearchCriteria `json:"criteria"` Criteria TenderSearchCriteria `json:"criteria"`
Limit int `json:"limit" valid:"range(1|100)~Limit must be between 1 and 100"` Limit int `json:"limit" valid:"range(1|100)~Limit must be between 1 and 100"`
Offset int `json:"offset" valid:"range(0|999999)~Offset must be non-negative"` Offset int `json:"offset" valid:"range(0|999999)~Offset must be non-negative"`
CompanyID *string `json:"company_id,omitempty"` // Optional company ID for match percentage calculation
} }
// ListTendersResponse represents the response for listing tenders // ListTendersResponse represents the response for listing tenders
type ListTendersResponse struct { type ListTendersResponse struct {
Tenders []Tender `json:"tenders"` Tenders []TenderResponse `json:"tenders"`
Total int64 `json:"total"` Total int64 `json:"total"`
Limit int `json:"limit"` Limit int `json:"limit"`
Offset int `json:"offset"` Offset int `json:"offset"`
} }
// SearchTendersRequest represents a request to search tenders // SearchTendersRequest represents a request to search tenders
@@ -160,6 +161,7 @@ type TenderResponse struct {
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
IsExpired bool `json:"is_expired"` IsExpired bool `json:"is_expired"`
HasErrors bool `json:"has_errors"` HasErrors bool `json:"has_errors"`
MatchPercentage *int `json:"match_percentage,omitempty"` // Match percentage for company (0-100)
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }
@@ -210,7 +212,12 @@ type ErrorResponse struct {
// ToTenderResponse converts a Tender entity to TenderResponse // ToTenderResponse converts a Tender entity to TenderResponse
func (t *Tender) ToTenderResponse() *TenderResponse { func (t *Tender) ToTenderResponse() *TenderResponse {
return &TenderResponse{ return t.ToTenderResponseWithMatch(nil)
}
// ToTenderResponseWithMatch converts a Tender entity to TenderResponse with optional match percentage
func (t *Tender) ToTenderResponseWithMatch(companyCPVCodes []string) *TenderResponse {
response := &TenderResponse{
ID: t.ID.Hex(), ID: t.ID.Hex(),
ContractNoticeID: t.ContractNoticeID, ContractNoticeID: t.ContractNoticeID,
NoticePublicationID: t.NoticePublicationID, NoticePublicationID: t.NoticePublicationID,
@@ -257,6 +264,14 @@ func (t *Tender) ToTenderResponse() *TenderResponse {
CreatedAt: t.CreatedAt, CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt, UpdatedAt: t.UpdatedAt,
} }
// Calculate match percentage if company CPV codes are provided
if companyCPVCodes != nil {
matchPercentage := t.CalculateMatchPercentage(companyCPVCodes)
response.MatchPercentage = &matchPercentage
}
return response
} }
// ToScrapingJobResponse converts a ScrapingJob entity to ScrapingJobResponse // ToScrapingJobResponse converts a ScrapingJob entity to ScrapingJobResponse
+36 -20
View File
@@ -154,11 +154,12 @@ func (h *TenderHandler) DeleteTender(c echo.Context) error {
// ListTenders retrieves tenders with pagination and filtering // ListTenders retrieves tenders with pagination and filtering
// @Summary List tenders // @Summary List tenders
// @Description Retrieve tenders with pagination, filtering, and search capabilities // @Description Retrieve tenders with pagination, filtering, and search capabilities with optional company-based matching
// @Tags Admin-Tenders // @Tags Admin-Tenders
// @Produce json // @Produce json
// @Param limit query int false "Number of items per page (default: 20, max: 100)" // @Param limit query int false "Number of items per page (default: 20, max: 100)"
// @Param offset query int false "Number of items to skip (default: 0)" // @Param offset query int false "Number of items to skip (default: 0)"
// @Param company_id query string false "Company ID for match percentage calculation and sorting"
// @Param query query string false "Search query for title and description" // @Param query query string false "Search query for title and description"
// @Param notice_type query string false "Filter by notice type code" // @Param notice_type query string false "Filter by notice type code"
// @Param procurement_type query string false "Filter by procurement type code" // @Param procurement_type query string false "Filter by procurement type code"
@@ -231,10 +232,17 @@ func (h *TenderHandler) ListTenders(c echo.Context) error {
} }
} }
// Parse company_id parameter for matching
var companyID *string
if companyIDParam := c.QueryParam("company_id"); companyIDParam != "" {
companyID = &companyIDParam
}
req := ListTendersRequest{ req := ListTendersRequest{
Criteria: criteria, Criteria: criteria,
Limit: limit, Limit: limit,
Offset: offset, Offset: offset,
CompanyID: companyID,
} }
result, err := h.service.ListTenders(c.Request().Context(), req) result, err := h.service.ListTenders(c.Request().Context(), req)
@@ -242,14 +250,8 @@ func (h *TenderHandler) ListTenders(c echo.Context) error {
return response.InternalServerError(c, "Failed to retrieve tenders") return response.InternalServerError(c, "Failed to retrieve tenders")
} }
// Convert tenders to response format
tenderResponses := make([]TenderResponse, len(result.Tenders))
for i, tender := range result.Tenders {
tenderResponses[i] = *tender.ToTenderResponse()
}
responseData := map[string]interface{}{ responseData := map[string]interface{}{
"tenders": tenderResponses, "tenders": result.Tenders,
"total": result.Total, "total": result.Total,
"limit": result.Limit, "limit": result.Limit,
"offset": result.Offset, "offset": result.Offset,
@@ -493,7 +495,7 @@ func (h *TenderHandler) CancelScrapingJob(c echo.Context) error {
// GetPublicTenders retrieves public tenders for mobile app // GetPublicTenders retrieves public tenders for mobile app
// @Summary Get public tenders // @Summary Get public tenders
// @Description Retrieve active tenders for mobile application users // @Description Retrieve active tenders for mobile application users with automatic company-based matching from customer profile
// @Tags Tenders // @Tags Tenders
// @Produce json // @Produce json
// @Param limit query int false "Number of items per page (default: 20, max: 50)" // @Param limit query int false "Number of items per page (default: 20, max: 50)"
@@ -504,7 +506,7 @@ func (h *TenderHandler) CancelScrapingJob(c echo.Context) error {
// @Success 200 {object} response.APIResponse{data=map[string]interface{}} // @Success 200 {object} response.APIResponse{data=map[string]interface{}}
// @Failure 400 {object} response.APIResponse // @Failure 400 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse // @Failure 500 {object} response.APIResponse
// @Router /tenders [get] // @Router /api/v1/tenders [get]
func (h *TenderHandler) GetPublicTenders(c echo.Context) error { func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
// Parse pagination parameters (more restrictive for public API) // Parse pagination parameters (more restrictive for public API)
limit := 20 limit := 20
@@ -543,10 +545,17 @@ func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
} }
} }
// Get company ID from customer context for automatic matching
var companyID *string
if customerCompanyID, ok := c.Get("company_id").(string); ok && customerCompanyID != "" {
companyID = &customerCompanyID
}
req := ListTendersRequest{ req := ListTendersRequest{
Criteria: criteria, Criteria: criteria,
Limit: limit, Limit: limit,
Offset: offset, Offset: offset,
CompanyID: companyID,
} }
result, err := h.service.ListTenders(c.Request().Context(), req) result, err := h.service.ListTenders(c.Request().Context(), req)
@@ -557,7 +566,7 @@ func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
// Convert tenders to public response format (limited fields) // Convert tenders to public response format (limited fields)
publicTenders := make([]map[string]interface{}, len(result.Tenders)) publicTenders := make([]map[string]interface{}, len(result.Tenders))
for i, tender := range result.Tenders { for i, tender := range result.Tenders {
publicTenders[i] = map[string]interface{}{ tenderData := map[string]interface{}{
"id": tender.ID, "id": tender.ID,
"title": tender.Title, "title": tender.Title,
"description": tender.Description, "description": tender.Description,
@@ -565,15 +574,22 @@ func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
"main_classification": tender.MainClassification, "main_classification": tender.MainClassification,
"estimated_value": tender.EstimatedValue, "estimated_value": tender.EstimatedValue,
"currency": tender.Currency, "currency": tender.Currency,
"formatted_estimated_value": tender.GetFormattedEstimatedValue(), "formatted_estimated_value": tender.FormattedEstimatedValue,
"tender_deadline": tender.TenderDeadline, "tender_deadline": tender.TenderDeadline,
"country_code": tender.CountryCode, "country_code": tender.CountryCode,
"city_name": tender.CityName, "city_name": tender.CityName,
"tender_url": tender.TenderURL, "tender_url": tender.TenderURL,
"publication_date": tender.PublicationDate, "publication_date": tender.PublicationDate,
"buyer_organization": tender.BuyerOrganization, "buyer_organization": tender.BuyerOrganization,
"is_expired": tender.IsExpired(), "is_expired": tender.IsExpired,
} }
// Add match percentage if available
if tender.MatchPercentage != nil {
tenderData["match_percentage"] = *tender.MatchPercentage
}
publicTenders[i] = tenderData
} }
responseData := map[string]interface{}{ responseData := map[string]interface{}{
@@ -595,7 +611,7 @@ func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
// @Success 200 {object} response.APIResponse{data=map[string]interface{}} // @Success 200 {object} response.APIResponse{data=map[string]interface{}}
// @Failure 404 {object} response.APIResponse // @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse // @Failure 500 {object} response.APIResponse
// @Router /tenders/{id} [get] // @Router /api/v1/tenders/{id} [get]
func (h *TenderHandler) GetPublicTenderDetails(c echo.Context) error { func (h *TenderHandler) GetPublicTenderDetails(c echo.Context) error {
id := c.Param("id") id := c.Param("id")
if id == "" { if id == "" {
+7
View File
@@ -86,6 +86,13 @@ func NewTenderRepository(mongoManager *mongopkg.ConnectionManager, logger logger
{Key: "notice_type_code", Value: 1}, {Key: "notice_type_code", Value: 1},
{Key: "status", Value: 1}, {Key: "status", Value: 1},
}), }),
// Index for CPV matching queries
*mongopkg.NewIndex("cpv_matching_idx", bson.D{
{Key: "status", Value: 1},
{Key: "main_classification", Value: 1},
{Key: "additional_classifications", Value: 1},
{Key: "publication_date", Value: -1},
}),
// Text index for search // Text index for search
*mongopkg.CreateTextIndex("search_idx", "title", "description"), *mongopkg.CreateTextIndex("search_idx", "title", "description"),
} }
+107 -8
View File
@@ -3,7 +3,9 @@ package tender
import ( import (
"context" "context"
"fmt" "fmt"
"sort"
"time" "time"
"tm/internal/company"
"tm/pkg/logger" "tm/pkg/logger"
"go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/bson/primitive"
@@ -36,15 +38,17 @@ type TenderService interface {
// tenderService implements TenderService interface // tenderService implements TenderService interface
type tenderService struct { type tenderService struct {
repository TenderRepository repository TenderRepository
logger logger.Logger companyService company.Service
logger logger.Logger
} }
// NewTenderService creates a new tender service // NewTenderService creates a new tender service
func NewTenderService(repository TenderRepository, logger logger.Logger) TenderService { func NewTenderService(repository TenderRepository, companyService company.Service, logger logger.Logger) TenderService {
return &tenderService{ return &tenderService{
repository: repository, repository: repository,
logger: logger, companyService: companyService,
logger: logger,
} }
} }
@@ -222,11 +226,19 @@ func (s *tenderService) DeleteTender(ctx context.Context, id string) error {
return nil return nil
} }
// TenderWithMatch represents a tender with calculated match percentage for sorting
type TenderWithMatch struct {
Tender *Tender
MatchPercentage int
DaysUntilDeadline int
}
// ListTenders retrieves tenders with pagination and filtering // ListTenders retrieves tenders with pagination and filtering
func (s *tenderService) ListTenders(ctx context.Context, req ListTendersRequest) (*ListTendersResponse, error) { func (s *tenderService) ListTenders(ctx context.Context, req ListTendersRequest) (*ListTendersResponse, error) {
s.logger.Info("Listing tenders", map[string]interface{}{ s.logger.Info("Listing tenders", map[string]interface{}{
"limit": req.Limit, "limit": req.Limit,
"offset": req.Offset, "offset": req.Offset,
"company_id": req.CompanyID,
}) })
if req.Limit <= 0 { if req.Limit <= 0 {
@@ -244,8 +256,95 @@ func (s *tenderService) ListTenders(ctx context.Context, req ListTendersRequest)
return nil, fmt.Errorf("failed to list tenders: %w", err) return nil, fmt.Errorf("failed to list tenders: %w", err)
} }
// If no company ID provided, return tenders without match percentage
if req.CompanyID == nil || *req.CompanyID == "" {
tenderResponses := make([]TenderResponse, 0, len(tenders))
for _, tender := range tenders {
tenderResponses = append(tenderResponses, *tender.ToTenderResponse())
}
return &ListTendersResponse{
Tenders: tenderResponses,
Total: total,
Limit: req.Limit,
Offset: req.Offset,
}, nil
}
// Get company CPV codes for match calculation
comp, err := s.companyService.GetCompanyByID(ctx, *req.CompanyID)
if err != nil {
s.logger.Error("Failed to get company for match calculation", map[string]interface{}{
"company_id": *req.CompanyID,
"error": err.Error(),
})
// Continue without match percentage if company not found
tenderResponses := make([]TenderResponse, 0, len(tenders))
for _, tender := range tenders {
tenderResponses = append(tenderResponses, *tender.ToTenderResponse())
}
return &ListTendersResponse{
Tenders: tenderResponses,
Total: total,
Limit: req.Limit,
Offset: req.Offset,
}, nil
}
var companyCPVCodes []string
if comp.Tags != nil && comp.Tags.CPVCodes != nil {
companyCPVCodes = comp.Tags.CPVCodes
}
// Calculate match percentages and prepare for sorting
tenderMatches := make([]TenderWithMatch, 0, len(tenders))
for _, tender := range tenders {
tenderMatch := TenderWithMatch{
Tender: &tender,
MatchPercentage: tender.CalculateMatchPercentage(companyCPVCodes),
DaysUntilDeadline: tender.GetDaysUntilDeadline(),
}
tenderMatches = append(tenderMatches, tenderMatch)
}
// Sort by match percentage (descending) and then by deadline preference
sort.Slice(tenderMatches, func(i, j int) bool {
matchI := tenderMatches[i].MatchPercentage
matchJ := tenderMatches[j].MatchPercentage
// First, sort by match percentage (descending)
if matchI != matchJ {
return matchI > matchJ
}
// For equal match percentages, prioritize tenders with >30 days deadline
daysI := tenderMatches[i].DaysUntilDeadline
daysJ := tenderMatches[j].DaysUntilDeadline
// Both have >30 days or both have <=30 days, sort by deadline descending (more time is better)
if (daysI > 30 && daysJ > 30) || (daysI <= 30 && daysJ <= 30) {
return daysI > daysJ
}
// One has >30 days, one has <=30 days - prioritize the one with >30 days
return daysI > 30
})
// Convert to TenderResponse objects with match percentages
tenderResponses := make([]TenderResponse, 0, len(tenderMatches))
for _, tenderMatch := range tenderMatches {
tenderResponses = append(tenderResponses, *tenderMatch.Tender.ToTenderResponseWithMatch(companyCPVCodes))
}
s.logger.Info("Tenders sorted by match percentage", map[string]interface{}{
"company_id": *req.CompanyID,
"company_cpv_codes": len(companyCPVCodes),
"total_tenders": len(tenderResponses),
})
return &ListTendersResponse{ return &ListTendersResponse{
Tenders: tenders, Tenders: tenderResponses,
Total: total, Total: total,
Limit: req.Limit, Limit: req.Limit,
Offset: req.Offset, Offset: req.Offset,