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:
+1630
-168
File diff suppressed because it is too large
Load Diff
+1630
-168
File diff suppressed because it is too large
Load Diff
+1048
-115
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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"},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user