perf: add pagination, filters, and fix Django sync pagination

This commit is contained in:
2026-04-30 15:34:25 -03:00
parent 19333cf713
commit badd54b4be
12 changed files with 413 additions and 176 deletions

View File

@@ -4,27 +4,97 @@ import (
"log"
"net/http"
"rrbec_server/internal/models"
"rrbec_server/internal/repository"
"rrbec_server/internal/service"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
type Handler struct {
svc *service.Service
svc *service.Service
syncer interface{ SyncProductsOnly() }
}
func NewHandler(svc *service.Service) *Handler {
return &Handler{svc: svc}
}
func (h *Handler) SetSyncer(syncer interface{ SyncProductsOnly() }) {
h.syncer = syncer
}
func parsePagination(c *gin.Context) *repository.PaginationParams {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
sort := c.DefaultQuery("sort", "id ASC")
params := &repository.PaginationParams{
Page: page,
Limit: limit,
Sort: sort,
Filters: make(map[string]interface{}),
}
if name := c.Query("name"); name != "" {
params.Filters["name"] = name
}
if active := c.Query("active"); active != "" {
if b, err := strconv.ParseBool(active); err == nil {
params.Filters["active"] = b
}
}
if status := c.Query("status"); status != "" {
params.Filters["status"] = status
}
if category := c.Query("category"); category != "" {
if c, err := strconv.ParseUint(category, 10, 64); err == nil {
params.Filters["category"] = uint(c)
}
}
if cuisine := c.Query("cuisine"); cuisine != "" {
if b, err := strconv.ParseBool(cuisine); err == nil {
params.Filters["cuisine"] = b
}
}
if dateFrom := c.Query("date_from"); dateFrom != "" {
if t, err := time.Parse("2006-01-02", dateFrom); err == nil {
params.Filters["date_from"] = t
}
}
if dateTo := c.Query("date_to"); dateTo != "" {
if t, err := time.Parse("2006-01-02", dateTo); err == nil {
params.Filters["date_to"] = t
}
}
if comandaID := c.Query("comanda_id"); comandaID != "" {
if c, err := strconv.ParseUint(comandaID, 10, 64); err == nil {
params.Filters["comanda_id"] = uint(c)
}
}
if canceled := c.Query("canceled"); canceled != "" {
if b, err := strconv.ParseBool(canceled); err == nil {
params.Filters["canceled"] = b
}
}
if delivered := c.Query("delivered"); delivered != "" {
if b, err := strconv.ParseBool(delivered); err == nil {
params.Filters["delivered"] = b
}
}
return params
}
func (h *Handler) GetProducts(c *gin.Context) {
products, err := h.svc.GetProducts()
params := parsePagination(c)
result, err := h.svc.GetProducts(params)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, products)
c.JSON(http.StatusOK, result)
}
func (h *Handler) CreateProduct(c *gin.Context) {
@@ -65,21 +135,23 @@ func (h *Handler) UpdateProduct(c *gin.Context) {
}
func (h *Handler) GetMesas(c *gin.Context) {
mesas, err := h.svc.GetMesas()
params := parsePagination(c)
result, err := h.svc.GetMesas(params)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, mesas)
c.JSON(http.StatusOK, result)
}
func (h *Handler) GetCategories(c *gin.Context) {
categories, err := h.svc.GetCategories()
params := parsePagination(c)
result, err := h.svc.GetCategories(params)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, categories)
c.JSON(http.StatusOK, result)
}
func (h *Handler) CreateCategory(c *gin.Context) {
@@ -120,30 +192,33 @@ func (h *Handler) UpdateCategory(c *gin.Context) {
}
func (h *Handler) GetTypePayments(c *gin.Context) {
types, err := h.svc.GetTypePayments()
params := parsePagination(c)
result, err := h.svc.GetTypePayments(params)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, types)
c.JSON(http.StatusOK, result)
}
func (h *Handler) GetClients(c *gin.Context) {
clients, err := h.svc.GetClients()
params := parsePagination(c)
result, err := h.svc.GetClients(params)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, clients)
c.JSON(http.StatusOK, result)
}
func (h *Handler) GetOrders(c *gin.Context) {
orders, err := h.svc.GetOrders()
params := parsePagination(c)
result, err := h.svc.GetOrders(params)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, orders)
c.JSON(http.StatusOK, result)
}
func (h *Handler) CreateOrder(c *gin.Context) {
@@ -244,12 +319,16 @@ func (h *Handler) SetOrderCanceled(c *gin.Context) {
}
func (h *Handler) GetPayments(c *gin.Context) {
payments, err := h.svc.GetPayments()
params := parsePagination(c)
if c.Query("sort") == "" {
params.Sort = "id DESC"
}
result, err := h.svc.GetPayments(params)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, payments)
c.JSON(http.StatusOK, result)
}
func (h *Handler) CreateComanda(c *gin.Context) {
@@ -400,12 +479,16 @@ func (h *Handler) PagarComanda(c *gin.Context) {
}
func (h *Handler) GetComandas(c *gin.Context) {
comandas, err := h.svc.GetComandas()
params := parsePagination(c)
if c.Query("sort") == "" {
params.Sort = "id DESC"
}
result, err := h.svc.GetComandas(params)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, comandas)
c.JSON(http.StatusOK, result)
}
func (h *Handler) GetComandaByID(c *gin.Context) {
@@ -455,3 +538,12 @@ func (h *Handler) GetCurrentUser(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"user_id": userID})
}
func (h *Handler) ResyncProducts(c *gin.Context) {
if h.syncer == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "syncer not configured"})
return
}
go h.syncer.SyncProductsOnly()
c.JSON(http.StatusOK, gin.H{"message": "Product resync started in background"})
}

View File

@@ -97,8 +97,8 @@ type Comanda struct {
DtOpen time.Time `json:"dt_open"`
DtClose *time.Time `json:"dt_close"`
// Nested items from Django (not stored in coma nda table)
Items []ProductComanda `gorm:"-" json:"items"`
// Nested items from Django (not stored in comanda table)
Items []ProductComanda `gorm:"foreignKey:ComandaID" json:"items"`
}
type ProductComanda struct {

View File

@@ -4,9 +4,131 @@ import (
"fmt"
"gorm.io/gorm"
"log"
"math"
"rrbec_server/internal/models"
"time"
)
type PaginationParams struct {
Page int
Limit int
Sort string
Filters map[string]interface{}
}
type PaginatedResponse struct {
Data interface{} `json:"data"`
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
func (p *PaginationParams) Apply(db *gorm.DB, model interface{}) (*gorm.DB, int64, error) {
if p.Page <= 0 {
p.Page = 1
}
if p.Limit <= 0 {
p.Limit = 50
}
if p.Limit > 200 {
p.Limit = 200
}
query := db.Model(model)
for key, value := range p.Filters {
if key == "status" {
if s, ok := value.(string); ok && s != "" {
query = query.Where("status = ?", s)
}
} else if key == "active" {
if b, ok := value.(bool); ok {
query = query.Where("active = ?", b)
}
} else if key == "category" {
if c, ok := value.(uint); ok && c > 0 {
query = query.Where("category = ?", c)
}
} else if key == "cuisine" {
if b, ok := value.(bool); ok {
query = query.Where("cuisine = ?", b)
}
} else if key == "date_from" {
if t, ok := value.(time.Time); ok {
query = query.Where("date_time >= ? OR dt_open >= ? OR created_at >= ?", t, t, t)
}
} else if key == "date_to" {
if t, ok := value.(time.Time); ok {
query = query.Where("date_time <= ? OR dt_open <= ? OR created_at <= ?", t, t, t)
}
} else if key == "comanda_id" {
if c, ok := value.(uint); ok && c > 0 {
query = query.Where("comanda_id = ?", c)
}
} else if key == "name" {
if n, ok := value.(string); ok && n != "" {
query = query.Where("name LIKE ?", "%"+n+"%")
}
} else if key == "canceled" {
if v, ok := value.(bool); ok {
if v {
query = query.Where("canceled IS NOT NULL")
} else {
query = query.Where("canceled IS NULL")
}
}
} else if key == "delivered" {
if v, ok := value.(bool); ok {
if v {
query = query.Where("delivered IS NOT NULL")
} else {
query = query.Where("delivered IS NULL")
}
}
}
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
sort := "id ASC"
if p.Sort != "" {
sort = p.Sort
}
offset := (p.Page - 1) * p.Limit
query = query.Order(sort).Offset(offset).Limit(p.Limit)
return query, total, nil
}
func (p *PaginationParams) Paginate(db *gorm.DB, model interface{}, result interface{}) (*PaginatedResponse, error) {
query, total, err := p.Apply(db, model)
if err != nil {
return nil, err
}
if err := query.Find(result).Error; err != nil {
return nil, err
}
totalPages := int(math.Ceil(float64(total) / float64(p.Limit)))
if totalPages == 0 {
totalPages = 1
}
return &PaginatedResponse{
Data: result,
Total: total,
Page: p.Page,
Limit: p.Limit,
TotalPages: totalPages,
}, nil
}
type Repository struct {
db *gorm.DB
}
@@ -16,10 +138,9 @@ func NewRepository(db *gorm.DB) *Repository {
}
// Mesa
func (r *Repository) GetMesas() ([]models.Mesa, error) {
func (r *Repository) GetMesas(params *PaginationParams) (*PaginatedResponse, error) {
var mesas []models.Mesa
err := r.db.Find(&mesas).Error
return mesas, err
return params.Paginate(r.db, &models.Mesa{}, &mesas)
}
func (r *Repository) CreateMesa(mesa *models.Mesa) error {
@@ -31,10 +152,9 @@ func (r *Repository) SaveMesa(mesa *models.Mesa) error {
}
// Client
func (r *Repository) GetClients() ([]models.Client, error) {
func (r *Repository) GetClients(params *PaginationParams) (*PaginatedResponse, error) {
var clients []models.Client
err := r.db.Find(&clients).Error
return clients, err
return params.Paginate(r.db, &models.Client{}, &clients)
}
func (r *Repository) CreateClient(client *models.Client) error {
@@ -46,10 +166,13 @@ func (r *Repository) SaveClient(client *models.Client) error {
}
// Product
func (r *Repository) GetProducts() ([]models.Product, error) {
func (r *Repository) GetProducts(params *PaginationParams) (*PaginatedResponse, error) {
var products []models.Product
err := r.db.Find(&products).Error
return products, err
return params.Paginate(r.db, &models.Product{}, &products)
}
func (r *Repository) GetProductsAll(products *[]models.Product) error {
return r.db.Find(products).Error
}
func (r *Repository) SaveProduct(product *models.Product) error {
@@ -88,20 +211,33 @@ func (r *Repository) CreateProduct(product *models.Product) error {
}
// Comanda
func (r *Repository) GetComandas() ([]models.Comanda, error) {
func (r *Repository) GetComandas(params *PaginationParams) (*PaginatedResponse, error) {
var comandas []models.Comanda
if err := r.db.Find(&comandas).Error; err != nil {
if params == nil {
params = &PaginationParams{Page: 1, Limit: 50}
}
query, total, err := params.Apply(r.db, &models.Comanda{})
if err != nil {
return nil, err
}
// For each comanda, load its items
for i := range comandas {
var items []models.ProductComanda
r.db.Where("comanda_id = ?", comandas[i].ID).Find(&items)
comandas[i].Items = items
if err := query.Preload("Items").Find(&comandas).Error; err != nil {
return nil, err
}
return comandas, nil
totalPages := int(math.Ceil(float64(total) / float64(params.Limit)))
if totalPages == 0 {
totalPages = 1
}
return &PaginatedResponse{
Data: comandas,
Total: total,
Page: params.Page,
Limit: params.Limit,
TotalPages: totalPages,
}, nil
}
func (r *Repository) CreateComanda(comanda *models.Comanda) error {
@@ -128,16 +264,14 @@ func (r *Repository) SaveTypePay(tp *models.TypePay) error {
return r.db.Save(tp).Error
}
func (r *Repository) GetCategories() ([]models.Category, error) {
func (r *Repository) GetCategories(params *PaginationParams) (*PaginatedResponse, error) {
var categories []models.Category
err := r.db.Find(&categories).Error
return categories, err
return params.Paginate(r.db, &models.Category{}, &categories)
}
func (r *Repository) GetTypePayments() ([]models.TypePay, error) {
func (r *Repository) GetTypePayments(params *PaginationParams) (*PaginatedResponse, error) {
var types []models.TypePay
err := r.db.Find(&types).Error
return types, err
return params.Paginate(r.db, &models.TypePay{}, &types)
}
func (r *Repository) GetComandaByID(id uint) (*models.Comanda, error) {
@@ -172,10 +306,9 @@ func (r *Repository) DeleteItem(itemID uint) error {
return r.db.Delete(&models.ProductComanda{}, itemID).Error
}
func (r *Repository) GetOrders() ([]models.Order, error) {
func (r *Repository) GetOrders(params *PaginationParams) (*PaginatedResponse, error) {
var orders []models.Order
err := r.db.Find(&orders).Error
return orders, err
return params.Paginate(r.db, &models.Order{}, &orders)
}
func (r *Repository) SaveOrder(order *models.Order) error {
@@ -216,10 +349,9 @@ func (r *Repository) UpdateOrderFields(id uint, updates map[string]interface{})
return r.db.Model(&models.Order{}).Where("id = ?", id).Updates(mappedUpdates).Error
}
func (r *Repository) GetPayments() ([]models.Payment, error) {
func (r *Repository) GetPayments(params *PaginationParams) (*PaginatedResponse, error) {
var payments []models.Payment
err := r.db.Find(&payments).Error
return payments, err
return params.Paginate(r.db, &models.Payment{}, &payments)
}
func (r *Repository) CreatePayment(payment *models.Payment) error {

View File

@@ -122,8 +122,8 @@ func (s *Service) SyncWithCloud() error {
return nil
}
func (s *Service) GetProducts() ([]models.Product, error) {
return s.repo.GetProducts()
func (s *Service) GetProducts(params *repository.PaginationParams) (*repository.PaginatedResponse, error) {
return s.repo.GetProducts(params)
}
func (s *Service) CreateProduct(product *models.Product) error {
@@ -134,20 +134,20 @@ func (s *Service) UpdateProduct(id uint, updates map[string]interface{}) error {
return s.repo.UpdateProductFields(id, updates)
}
func (s *Service) GetMesas() ([]models.Mesa, error) {
return s.repo.GetMesas()
func (s *Service) GetMesas(params *repository.PaginationParams) (*repository.PaginatedResponse, error) {
return s.repo.GetMesas(params)
}
func (s *Service) GetComandas() ([]models.Comanda, error) {
return s.repo.GetComandas()
func (s *Service) GetComandas(params *repository.PaginationParams) (*repository.PaginatedResponse, error) {
return s.repo.GetComandas(params)
}
func (s *Service) GetComandaByID(id uint) (*models.Comanda, error) {
return s.repo.GetComandaByID(id)
}
func (s *Service) GetCategories() ([]models.Category, error) {
return s.repo.GetCategories()
func (s *Service) GetCategories(params *repository.PaginationParams) (*repository.PaginatedResponse, error) {
return s.repo.GetCategories(params)
}
func (s *Service) CreateCategory(cat *models.Category) error {
@@ -158,16 +158,16 @@ func (s *Service) UpdateCategory(id uint, updates map[string]interface{}) error
return s.repo.UpdateCategoryFields(id, updates)
}
func (s *Service) GetTypePayments() ([]models.TypePay, error) {
return s.repo.GetTypePayments()
func (s *Service) GetTypePayments(params *repository.PaginationParams) (*repository.PaginatedResponse, error) {
return s.repo.GetTypePayments(params)
}
func (s *Service) GetClients() ([]models.Client, error) {
return s.repo.GetClients()
func (s *Service) GetClients(params *repository.PaginationParams) (*repository.PaginatedResponse, error) {
return s.repo.GetClients(params)
}
func (s *Service) GetOrders() ([]models.Order, error) {
return s.repo.GetOrders()
func (s *Service) GetOrders(params *repository.PaginationParams) (*repository.PaginatedResponse, error) {
return s.repo.GetOrders(params)
}
func (s *Service) CreateOrder(order *models.Order) error {
@@ -199,8 +199,8 @@ func (s *Service) SetOrderCanceled(id uint) error {
return s.repo.UpdateOrderFields(id, map[string]interface{}{"canceled": &now})
}
func (s *Service) GetPayments() ([]models.Payment, error) {
return s.repo.GetPayments()
func (s *Service) GetPayments(params *repository.PaginationParams) (*repository.PaginatedResponse, error) {
return s.repo.GetPayments(params)
}
func (s *Service) DeleteItem(itemID uint) error {

View File

@@ -288,48 +288,76 @@ func (s *Syncer) InitialSync() {
log.Println("Initial sync completed successfully.")
}
func (s *Syncer) fetchFromDjango(endpoint string, target interface{}) {
func (s *Syncer) fetchAllFromDjango(endpoint string, target interface{}) {
endpoint = strings.Trim(endpoint, "/")
url := ""
if strings.Contains(endpoint, "?") {
url = fmt.Sprintf("%s/api/v1/%s", s.djangoURL, endpoint)
} else {
url = fmt.Sprintf("%s/api/v1/%s/", s.djangoURL, endpoint)
}
url := fmt.Sprintf("%s/api/v1/%s/", s.djangoURL, endpoint)
token := s.accessToken
if token == "" {
token = s.masterPass
}
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Printf("Failed to fetch %s: %v", endpoint, err)
return
}
defer resp.Body.Close()
allData := []json.RawMessage{}
bodyBytes, _ := io.ReadAll(resp.Body)
for url != "" {
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
if err := json.Unmarshal(bodyBytes, target); err != nil {
var paginated struct {
Results json.RawMessage `json:"results"`
resp, err := client.Do(req)
if err != nil {
log.Printf("Failed to fetch %s: %v", endpoint, err)
return
}
if err2 := json.Unmarshal(bodyBytes, &paginated); err2 == nil && paginated.Results != nil {
if err3 := json.Unmarshal(paginated.Results, target); err3 != nil {
log.Printf("Failed to decode paginated %s: %v", endpoint, err3)
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if s.IsTokenExpired(bodyBytes) {
log.Printf("Token expired, refreshing...")
if err := s.RefreshAccessToken(); err == nil {
token = s.accessToken
continue
}
return
}
var paginated struct {
Results json.RawMessage `json:"results"`
Next *string `json:"next"`
Previous *string `json:"previous"`
Count int `json:"count"`
}
if err := json.Unmarshal(bodyBytes, &paginated); err == nil && paginated.Results != nil {
var items []json.RawMessage
if err := json.Unmarshal(paginated.Results, &items); err == nil {
allData = append(allData, items...)
}
if paginated.Next != nil && *paginated.Next != "" {
url = *paginated.Next
} else {
url = ""
}
} else {
log.Printf("Failed to decode %s: %v", endpoint, err)
var items []json.RawMessage
if err := json.Unmarshal(bodyBytes, &items); err == nil {
allData = append(allData, items...)
}
url = ""
}
}
log.Printf("Synchronized %s from cloud", endpoint)
if len(allData) > 0 {
combined, _ := json.Marshal(allData)
json.Unmarshal(combined, target)
}
log.Printf("Synchronized %d %s from cloud", len(allData), endpoint)
}
func (s *Syncer) fetchFromDjango(endpoint string, target interface{}) {
s.fetchAllFromDjango(endpoint, target)
}
func (s *Syncer) fetchFromDjangoRaw(endpoint string) []byte {
@@ -866,6 +894,75 @@ func (s *Syncer) sendMultipartRequest(method, endpoint string, data map[string]i
return nil, fmt.Errorf("django returned status: %d | Response: %s", resp.StatusCode, string(respBody))
}
func (s *Syncer) SyncProductsOnly() {
log.Println("Starting products-only sync...")
var products []models.Product
s.fetchAllFromDjango("products", &products)
log.Printf("Fetched %d products from Django", len(products))
// Build a map of cloud products by ID
cloudMap := make(map[uint]models.Product)
for _, p := range products {
cloudMap[p.ID] = p
}
// Get all local products
var localProducts []models.Product
s.repo.GetProductsAll(&localProducts)
localMap := make(map[uint]models.Product)
for _, p := range localProducts {
localMap[p.ID] = p
}
created := 0
updated := 0
failed := 0
for _, cp := range products {
cp.Sincronizado = true
if cp.UUID == "" {
if lp, exists := localMap[cp.ID]; exists && lp.UUID != "" {
cp.UUID = lp.UUID
} else {
cp.UUID = uuid.New().String()
}
}
var err error
if _, exists := localMap[cp.ID]; exists {
err = s.repo.SaveProduct(&cp)
if err == nil {
updated++
}
} else {
err = s.repo.CreateProduct(&cp)
if err == nil {
created++
}
}
if err != nil {
failed++
log.Printf("ERROR syncing product %d (%s): %v", cp.ID, cp.Name, err)
}
}
// Delete local products that no longer exist in cloud
deleted := 0
for _, lp := range localProducts {
if _, exists := cloudMap[lp.ID]; !exists {
if err := s.repo.DeleteByID("Product", lp.ID); err != nil {
log.Printf("ERROR deleting product %d: %v", lp.ID, err)
} else {
deleted++
log.Printf("Deleted local product %d (no longer in cloud)", lp.ID)
}
}
}
log.Printf("Products sync complete: %d created, %d updated, %d deleted, %d failed", created, updated, deleted, failed)
}
func (s *Syncer) syncUsers() {
// Optional: logic for background sync of users if needed
}