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
+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.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
type ListTendersRequest struct {
Criteria TenderSearchCriteria `json:"criteria"`
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"`
Criteria TenderSearchCriteria `json:"criteria"`
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"`
CompanyID *string `json:"company_id,omitempty"` // Optional company ID for match percentage calculation
}
// ListTendersResponse represents the response for listing tenders
type ListTendersResponse struct {
Tenders []Tender `json:"tenders"`
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Tenders []TenderResponse `json:"tenders"`
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// SearchTendersRequest represents a request to search tenders
@@ -160,6 +161,7 @@ type TenderResponse struct {
IsActive bool `json:"is_active"`
IsExpired bool `json:"is_expired"`
HasErrors bool `json:"has_errors"`
MatchPercentage *int `json:"match_percentage,omitempty"` // Match percentage for company (0-100)
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
@@ -210,7 +212,12 @@ type ErrorResponse struct {
// ToTenderResponse converts a Tender entity to 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(),
ContractNoticeID: t.ContractNoticeID,
NoticePublicationID: t.NoticePublicationID,
@@ -257,6 +264,14 @@ func (t *Tender) ToTenderResponse() *TenderResponse {
CreatedAt: t.CreatedAt,
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
+36 -20
View File
@@ -154,11 +154,12 @@ func (h *TenderHandler) DeleteTender(c echo.Context) error {
// ListTenders retrieves tenders with pagination and filtering
// @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
// @Produce json
// @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 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 notice_type query string false "Filter by notice 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{
Criteria: criteria,
Limit: limit,
Offset: offset,
Criteria: criteria,
Limit: limit,
Offset: offset,
CompanyID: companyID,
}
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")
}
// 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{}{
"tenders": tenderResponses,
"tenders": result.Tenders,
"total": result.Total,
"limit": result.Limit,
"offset": result.Offset,
@@ -493,7 +495,7 @@ func (h *TenderHandler) CancelScrapingJob(c echo.Context) error {
// GetPublicTenders retrieves public tenders for mobile app
// @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
// @Produce json
// @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{}}
// @Failure 400 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /tenders [get]
// @Router /api/v1/tenders [get]
func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
// Parse pagination parameters (more restrictive for public API)
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{
Criteria: criteria,
Limit: limit,
Offset: offset,
Criteria: criteria,
Limit: limit,
Offset: offset,
CompanyID: companyID,
}
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)
publicTenders := make([]map[string]interface{}, len(result.Tenders))
for i, tender := range result.Tenders {
publicTenders[i] = map[string]interface{}{
tenderData := map[string]interface{}{
"id": tender.ID,
"title": tender.Title,
"description": tender.Description,
@@ -565,15 +574,22 @@ func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
"main_classification": tender.MainClassification,
"estimated_value": tender.EstimatedValue,
"currency": tender.Currency,
"formatted_estimated_value": tender.GetFormattedEstimatedValue(),
"formatted_estimated_value": tender.FormattedEstimatedValue,
"tender_deadline": tender.TenderDeadline,
"country_code": tender.CountryCode,
"city_name": tender.CityName,
"tender_url": tender.TenderURL,
"publication_date": tender.PublicationDate,
"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{}{
@@ -595,7 +611,7 @@ func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
// @Success 200 {object} response.APIResponse{data=map[string]interface{}}
// @Failure 404 {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 {
id := c.Param("id")
if id == "" {
+7
View File
@@ -86,6 +86,13 @@ func NewTenderRepository(mongoManager *mongopkg.ConnectionManager, logger logger
{Key: "notice_type_code", 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
*mongopkg.CreateTextIndex("search_idx", "title", "description"),
}
+107 -8
View File
@@ -3,7 +3,9 @@ package tender
import (
"context"
"fmt"
"sort"
"time"
"tm/internal/company"
"tm/pkg/logger"
"go.mongodb.org/mongo-driver/bson/primitive"
@@ -36,15 +38,17 @@ type TenderService interface {
// tenderService implements TenderService interface
type tenderService struct {
repository TenderRepository
logger logger.Logger
repository TenderRepository
companyService company.Service
logger logger.Logger
}
// 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{
repository: repository,
logger: logger,
repository: repository,
companyService: companyService,
logger: logger,
}
}
@@ -222,11 +226,19 @@ func (s *tenderService) DeleteTender(ctx context.Context, id string) error {
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
func (s *tenderService) ListTenders(ctx context.Context, req ListTendersRequest) (*ListTendersResponse, error) {
s.logger.Info("Listing tenders", map[string]interface{}{
"limit": req.Limit,
"offset": req.Offset,
"limit": req.Limit,
"offset": req.Offset,
"company_id": req.CompanyID,
})
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)
}
// 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{
Tenders: tenders,
Tenders: tenderResponses,
Total: total,
Limit: req.Limit,
Offset: req.Offset,