feat: RRBEC Local Server - Go backend with Django sync

- Implement local-first architecture with SQLite
- Add bidirectional sync with Django via ChangeLog
- JWT authentication with auto-refresh token
- REST API for products, orders, commands, payments
- Stock management with automatic deduction
This commit is contained in:
2026-04-04 17:38:40 -03:00
commit 936aad779b
20 changed files with 3159 additions and 0 deletions

View File

@@ -0,0 +1,457 @@
package api
import (
"log"
"net/http"
"rrbec_server/internal/models"
"rrbec_server/internal/service"
"strconv"
"github.com/gin-gonic/gin"
)
type Handler struct {
svc *service.Service
}
func NewHandler(svc *service.Service) *Handler {
return &Handler{svc: svc}
}
func (h *Handler) GetProducts(c *gin.Context) {
products, err := h.svc.GetProducts()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, products)
}
func (h *Handler) CreateProduct(c *gin.Context) {
var product models.Product
if err := c.ShouldBindJSON(&product); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.svc.CreateProduct(&product); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"message": "Product created successfully", "product": product})
}
func (h *Handler) UpdateProduct(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"})
return
}
var updates map[string]interface{}
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.svc.UpdateProduct(uint(id), updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Product updated successfully"})
}
func (h *Handler) GetMesas(c *gin.Context) {
mesas, err := h.svc.GetMesas()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, mesas)
}
func (h *Handler) GetCategories(c *gin.Context) {
categories, err := h.svc.GetCategories()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, categories)
}
func (h *Handler) CreateCategory(c *gin.Context) {
var cat models.Category
if err := c.ShouldBindJSON(&cat); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.svc.CreateCategory(&cat); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, cat)
}
func (h *Handler) UpdateCategory(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"})
return
}
var updates map[string]interface{}
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.svc.UpdateCategory(uint(id), updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Category updated successfully"})
}
func (h *Handler) GetTypePayments(c *gin.Context) {
types, err := h.svc.GetTypePayments()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, types)
}
func (h *Handler) GetClients(c *gin.Context) {
clients, err := h.svc.GetClients()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, clients)
}
func (h *Handler) GetOrders(c *gin.Context) {
orders, err := h.svc.GetOrders()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, orders)
}
func (h *Handler) CreateOrder(c *gin.Context) {
var order models.Order
if err := c.ShouldBindJSON(&order); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.svc.CreateOrder(&order); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, order)
}
func (h *Handler) UpdateOrder(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"})
return
}
var updates map[string]interface{}
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.svc.UpdateOrder(uint(id), updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Order updated successfully"})
}
func (h *Handler) SetOrderPreparing(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"})
return
}
if err := h.svc.SetOrderPreparing(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Order is now preparing"})
}
func (h *Handler) SetOrderFinished(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"})
return
}
if err := h.svc.SetOrderFinished(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Order finished"})
}
func (h *Handler) SetOrderDelivered(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"})
return
}
if err := h.svc.SetOrderDelivered(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Order delivered"})
}
func (h *Handler) SetOrderCanceled(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"})
return
}
if err := h.svc.SetOrderCanceled(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Order canceled"})
}
func (h *Handler) GetPayments(c *gin.Context) {
payments, err := h.svc.GetPayments()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, payments)
}
func (h *Handler) CreateComanda(c *gin.Context) {
var comanda models.Comanda
if err := c.ShouldBindJSON(&comanda); err != nil {
log.Printf("DEBUG: CreateComanda bind error: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
log.Printf("DEBUG: CreateComanda received: MesaID=%d, UserID=%d, ClientID=%v", comanda.MesaID, comanda.UserID, comanda.ClientID)
if comanda.MesaID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Mesa ID is required for sync consistency"})
return
}
if err := h.svc.CreateComanda(&comanda); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, comanda)
}
func (h *Handler) AddItemToComanda(c *gin.Context) {
var item models.ProductComanda
if err := c.ShouldBindJSON(&item); err != nil {
log.Printf("DEBUG: AddItem bind error: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
log.Printf("DEBUG: AddItem received: ComandaID=%d, ProductID=%d", item.ComandaID, item.ProductID)
if item.ComandaID == 0 || item.ProductID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ComandaID and ProductID are required for sync"})
return
}
if err := h.svc.AddItemToComandaRaw(&item); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, item)
}
func (h *Handler) DeleteItemFromComanda(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"})
return
}
if err := h.svc.DeleteItem(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Item deleted successfully"})
}
func (h *Handler) ClearComanda(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"})
return
}
if err := h.svc.ClearComanda(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Comanda cleared and closed"})
}
func (h *Handler) UpdateComanda(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"})
return
}
var updates map[string]interface{}
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.svc.UpdateComanda(uint(id), updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Comanda updated successfully"})
}
func (h *Handler) PagarComanda(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"})
return
}
var rawData map[string]interface{}
if err := c.ShouldBindJSON(&rawData); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
log.Printf("DEBUG: PagarComanda RAW JSON: %v", rawData)
var payment models.Payment
if v, ok := rawData["value"].(float64); ok {
payment.Value = models.Price(v)
}
if v, ok := rawData["type_pay"].(float64); ok {
payment.TypePayID = uint(v)
} else if v, ok := rawData["id_type_pay"].(float64); ok {
payment.TypePayID = uint(v)
}
if v, ok := rawData["client"].(float64); ok {
clientId := uint(v)
payment.ClientID = &clientId
} else if v, ok := rawData["client_id"].(float64); ok {
clientId := uint(v)
payment.ClientID = &clientId
}
if v, ok := rawData["description"].(string); ok {
payment.Description = v
}
status := "CLOSED"
if v, ok := rawData["status"].(string); ok {
status = v
}
if err := h.svc.PagarComanda(uint(id), &payment, status); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Payment registered and comanda updated to " + status})
}
func (h *Handler) GetComandas(c *gin.Context) {
comandas, err := h.svc.GetComandas()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, comandas)
}
func (h *Handler) GetComandaByID(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id format"})
return
}
comanda, err := h.svc.GetComandaByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "comanda not found"})
return
}
c.JSON(http.StatusOK, comanda)
}
func (h *Handler) Login(c *gin.Context) {
var input struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.svc.Login(input.Username, input.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Login successful",
"user": user,
})
}
func (h *Handler) GetCurrentUser(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
c.JSON(http.StatusOK, gin.H{"user_id": userID})
}

View File

@@ -0,0 +1,24 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
)
// SimpleAuthMiddleware checks for a "User-ID" header for simplicity as requested.
// This can be expanded to use JWT or Cookies if needed.
func SimpleAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetHeader("X-User-ID")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
c.Abort()
return
}
// Map the user ID to the context for later use
c.Set("userID", userID)
c.Next()
}
}

View File

@@ -0,0 +1,40 @@
package config
import (
"log"
"os"
"github.com/joho/godotenv"
)
type Config struct {
Port string
DBName string
DjangoBaseURL string
DjangoMasterUser string
DjangoMasterPassword string
}
var AppConfig *Config
func LoadConfig() {
err := godotenv.Load()
if err != nil {
log.Println("No .env file found, using defaults")
}
AppConfig = &Config{
Port: getEnv("PORT", "8080"),
DBName: getEnv("DB_NAME", "rrbec.db"),
DjangoBaseURL: getEnv("DJANGO_BASE_URL", ""),
DjangoMasterUser: getEnv("DJANGO_MASTER_USER", ""),
DjangoMasterPassword: getEnv("DJANGO_MASTER_PASSWORD", ""),
}
}
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}

View File

@@ -0,0 +1,40 @@
package database
import (
"log"
"rrbec_server/internal/models"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
var DB *gorm.DB
func InitDB(dataSourceName string) *gorm.DB {
var err error
DB, err = gorm.Open(sqlite.Open(dataSourceName), &gorm.Config{})
if err != nil {
log.Fatalf("failed to connect database: %v", err)
}
// Auto Migration
err = DB.AutoMigrate(
&models.Mesa{},
&models.Client{},
&models.Category{},
&models.Product{},
&models.ProductComponent{},
&models.Comanda{},
&models.ProductComanda{},
&models.Order{},
&models.TypePay{},
&models.Payment{},
&models.User{},
&models.SyncState{},
)
if err != nil {
log.Fatalf("failed to migrate database: %v", err)
}
return DB
}

View File

@@ -0,0 +1,177 @@
package models
import (
"strconv"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// Custom type to handle Price as string or float from JSON
type Price float64
func (p *Price) UnmarshalJSON(data []byte) error {
if len(data) == 0 {
return nil
}
s := string(data)
// Remove quotes if it's a string
if s[0] == '"' {
s = s[1 : len(s)-1]
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return err
}
*p = Price(f)
return nil
}
// Base model with UUID and sync fields
type Base struct {
ID uint `gorm:"primaryKey" json:"id"`
UUID string `gorm:"uniqueIndex" json:"uuid"`
Sincronizado bool `gorm:"default:false" json:"sincronizado"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (b *Base) BeforeCreate(tx *gorm.DB) (err error) {
if b.UUID == "" {
b.UUID = uuid.New().String()
}
return
}
type Mesa struct {
Base
Name string `json:"name"`
Location string `json:"location"`
Active bool `gorm:"default:false" json:"active"`
}
type Client struct {
Base
Name string `json:"name"`
Debt Price `gorm:"default:0" json:"debt"`
CreatedAt time.Time `json:"created_at"`
Active bool `gorm:"default:true" json:"active"`
Contact string `json:"contact"`
}
type Category struct {
Base
Name string `json:"name"`
Active bool `gorm:"default:true" json:"active"`
}
type Product struct {
Base
Name string `json:"name"`
Description string `json:"description"`
Image string `json:"image"` // URL or Path
Price Price `json:"price"`
Quantity int `gorm:"default:0" json:"quantity"`
CategoryID uint `gorm:"column:category" json:"category"`
Cuisine bool `gorm:"default:false" json:"cuisine"`
Active bool `gorm:"default:true" json:"active"`
UnitOfMeasure *uint `gorm:"column:unit_of_measure" json:"unit_of_measure"`
}
type ProductComponent struct {
Base
CompositeProductID uint `json:"composite_product"`
ComponentProductID uint `json:"component_product"`
QuantityRequired float64 `json:"quantity_required"`
}
type Comanda struct {
Base
MesaID uint `json:"mesa"`
UserID uint `json:"user"`
TypePayID *uint `json:"type_pay"`
ClientID *uint `json:"client"`
Name string `json:"name"`
Status string `json:"status"` // OPEN, CLOSED, FIADO
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"`
}
type ProductComanda struct {
Base
ComandaID uint `json:"comanda"`
ProductID uint `json:"product"`
ProductName string `gorm:"-" json:"product_name"` // Read-only
ProductPrice Price `gorm:"-" json:"product_price"` // Read-only
DateTime time.Time `json:"data_time"`
Applicant string `json:"applicant"`
Obs string `gorm:"-" json:"obs"` // From Order
}
type Order struct {
Base
ProductComandaID *uint `json:"productComanda"`
ProductID uint `json:"id_product"`
ComandaID uint `json:"id_comanda"`
Obs string `json:"obs"`
Queue time.Time `json:"queue"`
Preparing *time.Time `json:"preparing"`
Finished *time.Time `json:"finished"`
Delivered *time.Time `json:"delivered"`
Canceled *time.Time `json:"canceled"`
}
func (o *Order) BeforeCreate(tx *gorm.DB) (err error) {
// Call Base.BeforeCreate to generate UUID
if err := o.Base.BeforeCreate(tx); err != nil {
return err
}
if o.Queue.IsZero() {
o.Queue = time.Now()
}
return
}
type TypePay struct {
Base
Name string `json:"name"`
Active bool `gorm:"default:true" json:"active"`
}
type Payment struct {
Base
Value Price `json:"value"`
TypePayID uint `json:"type_pay"`
ComandaID uint `json:"comanda"`
ClientID *uint `json:"client"`
Description string `json:"description"`
DateTime time.Time `json:"datetime"`
}
type User struct {
Base
Username string `gorm:"uniqueIndex" json:"username"`
Password string `json:"password"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
IsStaff bool `json:"is_staff"`
IsActive bool `json:"is_active"`
}
type SyncState struct {
ID uint `gorm:"primaryKey"`
LastSyncID int `gorm:"default:0"`
}
type ChangeLog struct {
ID int `json:"id"`
ModelName string `json:"model_name"`
ObjectID uint `json:"object_id"`
Action string `json:"action"` // SAVE or DELETE
Timestamp time.Time `json:"timestamp"`
}

View File

@@ -0,0 +1,410 @@
package repository
import (
"fmt"
"gorm.io/gorm"
"log"
"rrbec_server/internal/models"
)
type Repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
// Mesa
func (r *Repository) GetMesas() ([]models.Mesa, error) {
var mesas []models.Mesa
err := r.db.Find(&mesas).Error
return mesas, err
}
func (r *Repository) CreateMesa(mesa *models.Mesa) error {
return r.db.Create(mesa).Error
}
func (r *Repository) SaveMesa(mesa *models.Mesa) error {
return r.db.Save(mesa).Error
}
// Client
func (r *Repository) GetClients() ([]models.Client, error) {
var clients []models.Client
err := r.db.Find(&clients).Error
return clients, err
}
func (r *Repository) CreateClient(client *models.Client) error {
return r.db.Create(client).Error
}
func (r *Repository) SaveClient(client *models.Client) error {
return r.db.Save(client).Error
}
// Product
func (r *Repository) GetProducts() ([]models.Product, error) {
var products []models.Product
err := r.db.Find(&products).Error
return products, err
}
func (r *Repository) SaveProduct(product *models.Product) error {
return r.db.Save(product).Error
}
func (r *Repository) GetProductByID(id uint) (*models.Product, error) {
var product models.Product
err := r.db.First(&product, id).Error
return &product, err
}
func (r *Repository) UpdateProductFields(id uint, updates map[string]interface{}) error {
updates["sincronizado"] = false
return r.db.Model(&models.Product{}).Where("id = ?", id).Updates(updates).Error
}
func (r *Repository) DeductStock(productID uint, quantity int) error {
return r.db.Model(&models.Product{}).Where("id = ?", productID).
Update("quantity", gorm.Expr("quantity - ?", quantity)).Error
}
func (r *Repository) RestoreStock(productID uint, quantity int) error {
return r.db.Model(&models.Product{}).Where("id = ?", productID).
Update("quantity", gorm.Expr("quantity + ?", quantity)).Error
}
func (r *Repository) GetProductComponents(productID uint) ([]models.ProductComponent, error) {
var components []models.ProductComponent
err := r.db.Where("composite_product = ?", productID).Find(&components).Error
return components, err
}
func (r *Repository) CreateProduct(product *models.Product) error {
return r.db.Create(product).Error
}
// Comanda
func (r *Repository) GetComandas() ([]models.Comanda, error) {
var comandas []models.Comanda
if err := r.db.Find(&comandas).Error; 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
}
return comandas, nil
}
func (r *Repository) CreateComanda(comanda *models.Comanda) error {
return r.db.Create(comanda).Error
}
func (r *Repository) SaveComanda(comanda *models.Comanda) error {
return r.db.Save(comanda).Error
}
func (r *Repository) SaveCategory(cat *models.Category) error {
return r.db.Save(cat).Error
}
func (r *Repository) CreateCategory(cat *models.Category) error {
return r.db.Create(cat).Error
}
func (r *Repository) UpdateCategoryFields(id uint, updates map[string]interface{}) error {
return r.db.Model(&models.Category{}).Where("id = ?", id).Updates(updates).Error
}
func (r *Repository) SaveTypePay(tp *models.TypePay) error {
return r.db.Save(tp).Error
}
func (r *Repository) GetCategories() ([]models.Category, error) {
var categories []models.Category
err := r.db.Find(&categories).Error
return categories, err
}
func (r *Repository) GetTypePayments() ([]models.TypePay, error) {
var types []models.TypePay
err := r.db.Find(&types).Error
return types, err
}
func (r *Repository) GetComandaByID(id uint) (*models.Comanda, error) {
var comanda models.Comanda
if err := r.db.First(&comanda, id).Error; err != nil {
return nil, err
}
var items []models.ProductComanda
r.db.Where("comanda_id = ?", comanda.ID).Find(&items)
comanda.Items = items
return &comanda, nil
}
// Items
func (r *Repository) AddItemToComanda(item *models.ProductComanda) error {
return r.db.Create(item).Error
}
func (r *Repository) SaveProductComanda(item *models.ProductComanda) error {
return r.db.Save(item).Error
}
func (r *Repository) GetItemsByComanda(comandaID uint) ([]models.ProductComanda, error) {
var items []models.ProductComanda
err := r.db.Where("comanda_id = ?", comandaID).Find(&items).Error
return items, err
}
func (r *Repository) DeleteItem(itemID uint) error {
return r.db.Delete(&models.ProductComanda{}, itemID).Error
}
func (r *Repository) GetOrders() ([]models.Order, error) {
var orders []models.Order
err := r.db.Find(&orders).Error
return orders, err
}
func (r *Repository) SaveOrder(order *models.Order) error {
return r.db.Save(order).Error
}
func (r *Repository) CreateOrder(order *models.Order) error {
return r.db.Create(order).Error
}
func (r *Repository) GetOrderByPC(pcID uint) (*models.Order, error) {
var order models.Order
err := r.db.Where("product_comanda_id = ?", pcID).First(&order).Error
return &order, err
}
func (r *Repository) UpdateOrder(order *models.Order) error {
return r.db.Save(order).Error
}
func (r *Repository) UpdateOrderFields(id uint, updates map[string]interface{}) error {
mappedUpdates := make(map[string]interface{})
for k, v := range updates {
switch k {
case "productComanda":
mappedUpdates["product_comanda_id"] = v
case "id_product":
mappedUpdates["product_id"] = v
case "id_comanda":
mappedUpdates["comanda_id"] = v
default:
mappedUpdates[k] = v
}
}
mappedUpdates["sincronizado"] = false
return r.db.Model(&models.Order{}).Where("id = ?", id).Updates(mappedUpdates).Error
}
func (r *Repository) GetPayments() ([]models.Payment, error) {
var payments []models.Payment
err := r.db.Find(&payments).Error
return payments, err
}
func (r *Repository) CreatePayment(payment *models.Payment) error {
return r.db.Create(payment).Error
}
func (r *Repository) SavePayment(payment *models.Payment) error {
return r.db.Save(payment).Error
}
// Sync
func (r *Repository) GetUnsynced() (map[string][]interface{}, error) {
// This is a simplified helper to find anything not synced.
// In a real app, you'd iterate per table.
return nil, nil // Placeholder for sync worker logic
}
func (r *Repository) MarkAsSynced(model interface{}, id uint) error {
return r.db.Model(model).Where("id = ?", id).Update("sincronizado", true).Error
}
func (r *Repository) SafeChangeID(tableName string, oldID, newID uint) error {
if oldID == newID {
return nil
}
var count int64
r.db.Table(tableName).Where("id = ?", newID).Count(&count)
if count > 0 {
squatterID := newID + 1000000
for {
var sCount int64
r.db.Table(tableName).Where("id = ?", squatterID).Count(&sCount)
if sCount == 0 {
break
}
squatterID++
}
log.Printf("COLLISION: ID %d in %s taken! Moving squatter to %d", newID, tableName, squatterID)
r.db.Exec(fmt.Sprintf("UPDATE %s SET id = ? WHERE id = ?", tableName), squatterID, newID)
if tableName == "comandas" {
r.UpdateFK("product_comandas", "comanda_id", newID, squatterID)
r.UpdateFK("orders", "id_comanda", newID, squatterID)
r.UpdateFK("payments", "comanda_id", newID, squatterID)
} else if tableName == "product_comandas" {
r.UpdateFK("orders", "product_comanda_id", newID, squatterID)
}
}
return r.UpdateID(tableName, oldID, newID)
}
func (r *Repository) UpdateID(tableName string, oldID, newID uint) error {
return r.db.Exec(fmt.Sprintf("UPDATE %s SET id = ? WHERE id = ?", tableName), newID, oldID).Error
}
func (r *Repository) UpdateFK(tableName, fkColumn string, oldID, newID uint) error {
return r.db.Exec(fmt.Sprintf("UPDATE %s SET %s = ? WHERE %s = ?", tableName, fkColumn, fkColumn), newID, oldID).Error
}
// User
func (r *Repository) GetUserByUsername(username string) (*models.User, error) {
var user models.User
err := r.db.Where("username = ?", username).First(&user).Error
return &user, err
}
func (r *Repository) SaveUser(user *models.User) error {
return r.db.Save(user).Error
}
func (r *Repository) UpdateComandaStatus(id uint, status string) error {
return r.db.Model(&models.Comanda{}).Where("id = ?", id).Updates(map[string]interface{}{"status": status, "sincronizado": false}).Error
}
func (r *Repository) ClearComandaItems(comandaID uint) error {
return r.db.Where("comanda_id = ?", comandaID).Delete(&models.ProductComanda{}).Error
}
func (r *Repository) GetLastSyncID() int {
var state models.SyncState
r.db.FirstOrCreate(&state)
return state.LastSyncID
}
func (r *Repository) SaveLastSyncID(id int) {
r.db.Model(&models.SyncState{}).Where("id = ?", 1).Update("last_sync_id", id)
}
func (r *Repository) GetUnsyncedComandas() ([]models.Comanda, error) {
var results []models.Comanda
err := r.db.Where("sincronizado = ?", false).Find(&results).Error
return results, err
}
func (r *Repository) GetUnsyncedItems() ([]models.ProductComanda, error) {
var results []models.ProductComanda
err := r.db.Where("sincronizado = ?", false).Find(&results).Error
return results, err
}
func (r *Repository) GetUnsyncedOrders() ([]models.Order, error) {
var results []models.Order
err := r.db.Where("sincronizado = ?", false).Find(&results).Error
return results, err
}
func (r *Repository) GetUnsyncedPayments() ([]models.Payment, error) {
var results []models.Payment
err := r.db.Where("sincronizado = ?", false).Find(&results).Error
return results, err
}
func (r *Repository) GetUnsyncedProducts() ([]models.Product, error) {
var results []models.Product
err := r.db.Where("sincronizado = ?", false).Find(&results).Error
return results, err
}
func (r *Repository) GetComandaToSync(id uint, dest *models.Comanda) error {
return r.db.First(dest, id).Error
}
func (r *Repository) GetItemToSync(id uint, dest *models.ProductComanda) error {
return r.db.First(dest, id).Error
}
func (r *Repository) GetPaymentToSync(id uint, dest *models.Payment) error {
return r.db.First(dest, id).Error
}
func (r *Repository) GetProductToSync(id uint, dest *models.Product) error {
return r.db.First(dest, id).Error
}
func (r *Repository) GetOrderToSync(id uint, dest *models.Order) error {
return r.db.First(dest, id).Error
}
func (r *Repository) DeleteByID(modelName string, id uint) error {
var model interface{}
switch modelName {
case "Product":
model = &models.Product{}
case "Comanda":
model = &models.Comanda{}
case "ProductComanda":
model = &models.ProductComanda{}
case "Order":
model = &models.Order{}
case "Client":
model = &models.Client{}
case "Categories":
model = &models.Category{}
case "Mesa":
model = &models.Mesa{}
case "Payments":
model = &models.Payment{}
default:
return nil
}
return r.db.Delete(model, id).Error
}
func (r *Repository) UpdateComandaFields(id uint, updates map[string]interface{}) error {
mappedUpdates := make(map[string]interface{})
for k, v := range updates {
switch k {
case "mesa":
mappedUpdates["mesa_id"] = v
case "user":
mappedUpdates["user_id"] = v
case "client":
mappedUpdates["client_id"] = v
case "type_pay":
mappedUpdates["type_pay_id"] = v
default:
mappedUpdates[k] = v
}
}
// Mark as unsynced so the background worker pushes the update
mappedUpdates["sincronizado"] = false
return r.db.Model(&models.Comanda{}).Where("id = ?", id).Updates(mappedUpdates).Error
}

View File

@@ -0,0 +1,280 @@
package service
import (
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"golang.org/x/crypto/pbkdf2"
"log"
"rrbec_server/internal/models"
"rrbec_server/internal/repository"
"strings"
"time"
)
type Service struct {
repo *repository.Repository
}
func NewService(repo *repository.Repository) *Service {
return &Service{repo: repo}
}
// Comanda Logic
func (s *Service) CreateComanda(comanda *models.Comanda) error {
comanda.DtOpen = time.Now()
comanda.Status = "OPEN"
return s.repo.CreateComanda(comanda)
}
// Item Addition and Stock Logic
func (s *Service) AddItemToComanda(comandaID, productID uint, applicant string) (*models.ProductComanda, error) {
comanda, err := s.repo.GetComandaByID(comandaID)
if err != nil {
return nil, err
}
if comanda.Status != "OPEN" {
return nil, errors.New("cannot add items to a closed or fiado comanda")
}
product, err := s.repo.GetProductByID(productID)
if err != nil {
return nil, errors.New("product not found")
}
if product.Quantity <= 0 {
return nil, errors.New("product out of stock")
}
item := &models.ProductComanda{
ComandaID: comandaID,
ProductID: productID,
ProductName: product.Name,
ProductPrice: product.Price,
DateTime: time.Now(),
Applicant: applicant,
}
err = s.repo.AddItemToComanda(item)
if err != nil {
return nil, err
}
if err := s.deductProductStock(productID, 1); err != nil {
log.Printf("Warning: failed to deduct stock for product %d: %v", productID, err)
}
if product.Cuisine {
order := &models.Order{
ProductComandaID: &item.ID,
ProductID: productID,
ComandaID: comandaID,
Queue: time.Now(),
}
s.repo.CreateOrder(order)
}
return item, nil
}
func (s *Service) deductProductStock(productID uint, quantity int) error {
components, err := s.repo.GetProductComponents(productID)
if err != nil {
return err
}
if len(components) > 0 {
for _, comp := range components {
if err := s.repo.DeductStock(comp.ComponentProductID, int(comp.QuantityRequired)); err != nil {
return err
}
}
return nil
}
return s.repo.DeductStock(productID, quantity)
}
func (s *Service) AddItemToComandaRaw(item *models.ProductComanda) error {
comanda, err := s.repo.GetComandaByID(item.ComandaID)
if err != nil {
return err
}
if comanda.Status != "OPEN" {
return errors.New("cannot add items to a closed or fiado comanda")
}
product, err := s.repo.GetProductByID(item.ProductID)
if err != nil {
return errors.New("product not found")
}
item.ProductName = product.Name
item.ProductPrice = product.Price
item.DateTime = time.Now()
return s.repo.AddItemToComanda(item)
}
// Sync Logic Placeholder
func (s *Service) SyncWithCloud() error {
// Task for the background worker
return nil
}
func (s *Service) GetProducts() ([]models.Product, error) {
return s.repo.GetProducts()
}
func (s *Service) CreateProduct(product *models.Product) error {
return s.repo.CreateProduct(product)
}
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) GetComandas() ([]models.Comanda, error) {
return s.repo.GetComandas()
}
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) CreateCategory(cat *models.Category) error {
return s.repo.CreateCategory(cat)
}
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) GetClients() ([]models.Client, error) {
return s.repo.GetClients()
}
func (s *Service) GetOrders() ([]models.Order, error) {
return s.repo.GetOrders()
}
func (s *Service) CreateOrder(order *models.Order) error {
order.Queue = time.Now()
return s.repo.CreateOrder(order)
}
func (s *Service) UpdateOrder(id uint, updates map[string]interface{}) error {
return s.repo.UpdateOrderFields(id, updates)
}
func (s *Service) SetOrderPreparing(id uint) error {
now := time.Now()
return s.repo.UpdateOrderFields(id, map[string]interface{}{"preparing": &now})
}
func (s *Service) SetOrderFinished(id uint) error {
now := time.Now()
return s.repo.UpdateOrderFields(id, map[string]interface{}{"finished": &now})
}
func (s *Service) SetOrderDelivered(id uint) error {
now := time.Now()
return s.repo.UpdateOrderFields(id, map[string]interface{}{"delivered": &now})
}
func (s *Service) SetOrderCanceled(id uint) error {
now := time.Now()
return s.repo.UpdateOrderFields(id, map[string]interface{}{"canceled": &now})
}
func (s *Service) GetPayments() ([]models.Payment, error) {
return s.repo.GetPayments()
}
func (s *Service) DeleteItem(itemID uint) error {
// 1. Check if there's an associated Order (Kitchen)
order, err := s.repo.GetOrderByPC(itemID)
if err == nil && order != nil {
// 2. Mark as Canceled
now := time.Now()
order.Canceled = &now
s.repo.UpdateOrder(order)
}
// 3. Delete the item
return s.repo.DeleteItem(itemID)
}
func (s *Service) ClearComanda(id uint) error {
if err := s.repo.ClearComandaItems(id); err != nil {
return err
}
return s.repo.UpdateComandaStatus(id, "CLOSED")
}
func (s *Service) PagarComanda(id uint, payment *models.Payment, status string) error {
payment.ComandaID = id
payment.DateTime = time.Now()
if err := s.repo.CreatePayment(payment); err != nil {
return err
}
if status == "" {
status = "CLOSED"
}
return s.repo.UpdateComandaStatus(id, status)
}
func (s *Service) UpdateComanda(id uint, updates map[string]interface{}) error {
return s.repo.UpdateComandaFields(id, updates)
}
// User Auth
func (s *Service) Login(username, password string) (*models.User, error) {
user, err := s.repo.GetUserByUsername(username)
if err != nil {
return nil, errors.New("invalid credentials")
}
if s.CheckDjangoPassword(password, user.Password) {
return user, nil
}
return nil, errors.New("invalid credentials")
}
func (s *Service) CheckDjangoPassword(password, hash string) bool {
parts := strings.Split(hash, "$")
if len(parts) != 4 {
return false
}
algorithm := parts[0]
if algorithm != "pbkdf2_sha256" {
return false
}
var iterations int
fmt.Sscanf(parts[1], "%d", &iterations)
salt := parts[2]
djangoHash := parts[3]
// PBKDF2 with SHA256
dk := pbkdf2.Key([]byte(password), []byte(salt), iterations, 32, sha256.New)
encoded := base64.StdEncoding.EncodeToString(dk)
return encoded == djangoHash
}

View File

@@ -0,0 +1,871 @@
package sync
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"rrbec_server/internal/models"
"rrbec_server/internal/repository"
"github.com/google/uuid"
)
type Syncer struct {
repo *repository.Repository
djangoURL string
masterUser string
masterPass string
accessToken string
refreshToken string
}
func NewSyncer(repo *repository.Repository, djangoURL, masterUser, masterPass string) *Syncer {
return &Syncer{
repo: repo,
djangoURL: djangoURL,
masterUser: masterUser,
masterPass: masterPass,
}
}
func (s *Syncer) Start() {
go func() {
// 1. Login to Django
token, err := s.LoginToDjango()
if err != nil {
log.Printf("Initial Django login failed: %v", err)
} else {
s.masterPass = token // Use token for future requests
// 2. Load Last Sync ID
lastID := s.repo.GetLastSyncID()
if lastID == 0 {
log.Println("Database is empty or fresh. Starting initial sync...")
s.InitialSync()
}
}
// 3. Sync Loop (Bidirectional)
for {
log.Println("Sync cycle started...")
s.processChangeLog() // Cloud -> Local
s.pushLocalChanges() // Local -> Cloud
time.Sleep(5 * time.Second)
}
}()
}
func (s *Syncer) ensureValidToken() {
if s.accessToken == "" {
token, err := s.LoginToDjango()
if err != nil {
log.Printf("Failed to login: %v", err)
return
}
s.accessToken = token
}
}
func (s *Syncer) ensureValidTokenWithRefresh() {
s.ensureValidToken()
if s.refreshToken == "" {
return
}
resp := s.fetchFromDjangoRaw("products")
if s.IsTokenExpired(resp) {
if err := s.RefreshAccessToken(); err != nil {
log.Printf("Token refresh failed, trying full login: %v", err)
s.accessToken = ""
s.ensureValidToken()
}
}
}
func (s *Syncer) LoginToDjango() (string, error) {
url := fmt.Sprintf("%s/api/v1/token/", s.djangoURL)
loginData := map[string]string{
"username": s.masterUser,
"password": s.masterPass,
}
jsonData, _ := json.Marshal(loginData)
resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("login failed with status: %d", resp.StatusCode)
}
var result struct {
Access string `json:"access"`
Refresh string `json:"refresh"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
s.accessToken = result.Access
s.refreshToken = result.Refresh
return result.Access, nil
}
func (s *Syncer) RefreshAccessToken() error {
url := fmt.Sprintf("%s/api/v1/token/refresh/", s.djangoURL)
refreshData := map[string]string{
"refresh": s.refreshToken,
}
jsonData, _ := json.Marshal(refreshData)
resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.Printf("Token refresh failed: %s", string(body))
return fmt.Errorf("refresh failed with status: %d", resp.StatusCode)
}
var result struct {
Access string `json:"access"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
s.accessToken = result.Access
log.Println("Token refreshed successfully")
return nil
}
func (s *Syncer) IsTokenExpired(responseBody []byte) bool {
var errResp struct {
Code string `json:"code"`
}
if json.Unmarshal(responseBody, &errResp) == nil {
return errResp.Code == "token_not_valid" || strings.Contains(string(responseBody), "token_not_valid")
}
return false
}
func (s *Syncer) InitialSync() {
// Sync Products
var products []models.Product
s.fetchFromDjango("products", &products)
for _, p := range products {
p.Sincronizado = true
if p.UUID == "" {
p.UUID = uuid.New().String()
}
s.repo.SaveProduct(&p)
}
// Sync Clients
var clients []models.Client
s.fetchFromDjango("clients", &clients)
for _, c := range clients {
c.Sincronizado = true
if c.UUID == "" {
c.UUID = uuid.New().String()
}
s.repo.SaveClient(&c)
}
// Sync Mesas
var mesas []models.Mesa
s.fetchFromDjango("mesas", &mesas)
for _, m := range mesas {
m.Sincronizado = true
if m.UUID == "" {
m.UUID = uuid.New().String()
}
s.repo.SaveMesa(&m)
}
// Sync Users
var users []models.User
s.fetchFromDjango("users", &users)
for _, u := range users {
u.Sincronizado = true
if u.UUID == "" {
u.UUID = uuid.New().String()
}
s.repo.SaveUser(&u)
}
// Sync Categories
var categories []models.Category
s.fetchFromDjango("categories", &categories)
for _, cat := range categories {
cat.Sincronizado = true
if cat.UUID == "" {
cat.UUID = uuid.New().String()
}
s.repo.SaveCategory(&cat)
}
// Sync Payment Types
var paymentTypes []models.TypePay
s.fetchFromDjango("payment-types", &paymentTypes)
for _, tp := range paymentTypes {
tp.Sincronizado = true
if tp.UUID == "" {
tp.UUID = uuid.New().String()
}
s.repo.SaveTypePay(&tp)
}
// Sync Comandas
var comandas []models.Comanda
s.fetchFromDjango("comandas", &comandas)
for _, co := range comandas {
co.Sincronizado = true
if co.UUID == "" {
co.UUID = uuid.New().String()
}
s.repo.SaveComanda(&co)
// Sync nested items from the serializer
for _, item := range co.Items {
item.Sincronizado = true
if item.UUID == "" {
item.UUID = uuid.New().String()
}
s.repo.SaveProductComanda(&item)
}
}
// Sync Comanda Items
var items []models.ProductComanda
s.fetchFromDjango("items-comanda", &items)
for _, it := range items {
it.Sincronizado = true
if it.UUID == "" {
it.UUID = uuid.New().String()
}
s.repo.SaveProductComanda(&it)
}
// Sync Orders
var orders []models.Order
s.fetchFromDjango("orders", &orders)
for _, o := range orders {
o.Sincronizado = true
if o.UUID == "" {
o.UUID = uuid.New().String()
}
s.repo.SaveOrder(&o)
}
// Sync Payments
var payments []models.Payment
s.fetchFromDjango("payments", &payments)
for _, pay := range payments {
pay.Sincronizado = true
if pay.UUID == "" {
pay.UUID = uuid.New().String()
}
s.repo.SavePayment(&pay)
}
log.Println("Initial sync completed successfully.")
}
func (s *Syncer) fetchFromDjango(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)
}
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()
bodyBytes, _ := io.ReadAll(resp.Body)
if err := json.Unmarshal(bodyBytes, target); err != nil {
var paginated struct {
Results json.RawMessage `json:"results"`
}
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)
}
} else {
log.Printf("Failed to decode %s: %v", endpoint, err)
}
}
log.Printf("Synchronized %s from cloud", endpoint)
}
func (s *Syncer) fetchFromDjangoRaw(endpoint string) []byte {
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)
}
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 nil
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if s.IsTokenExpired(bodyBytes) {
log.Printf("Token expired, refreshing...")
if err := s.RefreshAccessToken(); err != nil {
log.Printf("Token refresh failed: %v", err)
} else {
return s.fetchFromDjangoRaw(endpoint)
}
}
log.Printf("DEBUG: Raw response for %s: %s", endpoint, string(bodyBytes))
return bodyBytes
}
func (s *Syncer) processChangeLog() {
lastID := s.repo.GetLastSyncID()
url := fmt.Sprintf("sync?since_id=%d", lastID)
var changes []models.ChangeLog
respBody := s.fetchFromDjangoRaw(url)
if respBody != nil {
if err := json.Unmarshal(respBody, &changes); err != nil {
log.Printf("ERROR: Failed to parse changelog: %v", err)
return
}
}
log.Printf("DEBUG: Received %d changes from Django", len(changes))
if len(changes) == 0 {
return
}
maxID := lastID
for _, change := range changes {
log.Printf("Processing change %s for %s (ID: %d)", change.Action, change.ModelName, change.ObjectID)
if change.Action == "SAVE" {
s.fetchAndSaveObject(change.ModelName, change.ObjectID)
} else if change.Action == "DELETE" {
s.repo.DeleteByID(change.ModelName, change.ObjectID)
}
if change.ID > maxID {
maxID = change.ID
}
}
s.repo.SaveLastSyncID(maxID)
}
func (s *Syncer) fetchAndSaveObject(modelName string, id uint) {
endpoint := ""
switch modelName {
case "Product":
endpoint = "products"
case "Comanda":
endpoint = "comandas"
case "ProductComanda":
endpoint = "items-comanda"
case "Order":
endpoint = "orders"
case "Client":
endpoint = "clients"
case "Categories":
endpoint = "categories"
case "Mesa":
endpoint = "mesas"
case "Payments":
endpoint = "payments"
default:
return
}
url := fmt.Sprintf("%s/%d", endpoint, id)
// Create temporary instance based on modelName
switch modelName {
case "Comanda":
var obj models.Comanda
s.fetchFromDjango(url, &obj)
if obj.UUID == "" {
var local models.Comanda
if err := s.repo.GetComandaToSync(obj.ID, &local); err == nil {
obj.UUID = local.UUID
}
if obj.UUID == "" {
obj.UUID = uuid.New().String()
}
}
obj.Sincronizado = true
s.repo.SaveComanda(&obj)
case "Product":
var obj models.Product
s.fetchFromDjango(url, &obj)
if obj.UUID == "" {
var local models.Product
if err := s.repo.GetProductToSync(obj.ID, &local); err == nil {
obj.UUID = local.UUID
}
if obj.UUID == "" {
obj.UUID = uuid.New().String()
}
}
obj.Sincronizado = true
s.repo.SaveProduct(&obj)
case "ProductComanda":
var obj models.ProductComanda
s.fetchFromDjango(url, &obj)
if obj.UUID == "" {
var local models.ProductComanda
if err := s.repo.GetItemToSync(obj.ID, &local); err == nil {
obj.UUID = local.UUID
}
if obj.UUID == "" {
obj.UUID = uuid.New().String()
}
}
obj.Sincronizado = true
s.repo.SaveProductComanda(&obj)
case "Order":
var obj models.Order
s.fetchFromDjango(url, &obj)
if obj.UUID == "" {
var local models.Order
if err := s.repo.GetOrderToSync(obj.ID, &local); err == nil {
obj.UUID = local.UUID
}
if obj.UUID == "" {
obj.UUID = uuid.New().String()
}
}
obj.Sincronizado = true
s.repo.SaveOrder(&obj)
case "Client":
var obj models.Client
s.fetchFromDjango(url, &obj)
if obj.UUID == "" {
obj.UUID = uuid.New().String()
}
obj.Sincronizado = true
s.repo.SaveClient(&obj)
case "Categories":
var obj models.Category
s.fetchFromDjango(url, &obj)
if obj.UUID == "" {
obj.UUID = uuid.New().String()
}
obj.Sincronizado = true
s.repo.SaveCategory(&obj)
case "Mesa":
var obj models.Mesa
s.fetchFromDjango(url, &obj)
if obj.UUID == "" {
obj.UUID = uuid.New().String()
}
obj.Sincronizado = true
s.repo.SaveMesa(&obj)
case "Payments":
var obj models.Payment
s.fetchFromDjango(url, &obj)
if obj.UUID == "" {
var local models.Payment
if err := s.repo.GetPaymentToSync(obj.ID, &local); err == nil {
obj.UUID = local.UUID
}
if obj.UUID == "" {
obj.UUID = uuid.New().String()
}
}
obj.Sincronizado = true
s.repo.SavePayment(&obj)
}
}
func (s *Syncer) pushLocalChanges() {
// 1. Sync Comandas
comandas, err := s.repo.GetUnsyncedComandas()
if err == nil && len(comandas) > 0 {
for _, c := range comandas {
if c.MesaID == 0 {
log.Printf("SKIP: Comanda %d has mesa 0. Correct data required.", c.ID)
continue
}
// Capture local ID before we might change it
oldID := c.ID
comandaName := c.Name
if comandaName == "" {
comandaName = fmt.Sprintf("Comanda %d", oldID)
}
payload := map[string]interface{}{
"mesa": c.MesaID,
"client": c.ClientID,
"user": c.UserID,
"status": c.Status,
"name": comandaName,
"uuid": c.UUID,
}
// Try PATCH first
endpoint := fmt.Sprintf("comandas/%d", c.ID)
respBody, err := s.sendRequest("PATCH", endpoint, payload)
if err != nil && strings.Contains(err.Error(), "status: 404") {
respBody, err = s.sendRequest("POST", "comandas", payload)
}
if err == nil {
var created models.Comanda
json.Unmarshal(respBody, &created)
// CRITICAL: Update local ID and children safely
if created.ID != 0 && created.ID != oldID {
s.repo.SafeChangeID("comandas", oldID, created.ID)
// Update items that were pointing to the old ID
s.repo.UpdateFK("product_comandas", "comanda_id", oldID, created.ID)
s.repo.UpdateFK("orders", "id_comanda", oldID, created.ID)
s.repo.UpdateFK("payments", "comanda_id", oldID, created.ID)
}
s.repo.MarkAsSynced(&models.Comanda{}, created.ID)
log.Printf("SUCCESS: Pushed Comanda %d (now %d) to cloud", oldID, created.ID)
} else {
log.Printf("ERROR pushing Comanda %d: %v", oldID, err)
}
}
}
// 2. Sync Items
items, err := s.repo.GetUnsyncedItems()
if err == nil && len(items) > 0 {
for _, it := range items {
oldID := it.ID
payload := map[string]interface{}{
"comanda": it.ComandaID,
"product": it.ProductID,
"applicant": it.Applicant,
"uuid": it.UUID,
}
endpoint := fmt.Sprintf("items-comanda/%d", it.ID)
respBody, err := s.sendRequest("PATCH", endpoint, payload)
if err != nil && strings.Contains(err.Error(), "status: 404") {
respBody, err = s.sendRequest("POST", "items-comanda", payload)
}
if err == nil {
var created models.ProductComanda
json.Unmarshal(respBody, &created)
if created.ID != 0 && created.ID != oldID {
s.repo.SafeChangeID("product_comandas", oldID, created.ID)
s.repo.UpdateFK("orders", "product_comanda_id", oldID, created.ID)
}
s.repo.MarkAsSynced(&models.ProductComanda{}, created.ID)
log.Printf("SUCCESS: Pushed Item %d (now %d) to cloud", oldID, created.ID)
} else {
log.Printf("ERROR pushing Item %d: %v", oldID, err)
}
}
}
// 3. Sync Orders
orders, err := s.repo.GetUnsyncedOrders()
if err == nil && len(orders) > 0 {
for _, o := range orders {
oldID := o.ID
payload := map[string]interface{}{
"productComanda": o.ProductComandaID,
"id_product": o.ProductID,
"id_comanda": o.ComandaID,
"obs": o.Obs,
"uuid": o.UUID,
}
// Optional: mapping timestamps if they exist
if !o.Queue.IsZero() {
payload["queue"] = o.Queue
}
if o.Preparing != nil {
payload["preparing"] = o.Preparing
}
if o.Finished != nil {
payload["finished"] = o.Finished
}
if o.Delivered != nil {
payload["delivered"] = o.Delivered
}
if o.Canceled != nil {
payload["canceled"] = o.Canceled
}
endpoint := fmt.Sprintf("orders/%d", o.ID)
respBody, err := s.sendRequest("PATCH", endpoint, payload)
if err != nil && strings.Contains(err.Error(), "status: 404") {
respBody, err = s.sendRequest("POST", "orders", payload)
}
if err == nil {
var created models.Order
json.Unmarshal(respBody, &created)
if created.ID != 0 && created.ID != oldID {
s.repo.SafeChangeID("orders", oldID, created.ID)
}
s.repo.MarkAsSynced(&models.Order{}, created.ID)
log.Printf("SUCCESS: Pushed Order %d (now %d) to cloud", oldID, created.ID)
} else {
log.Printf("ERROR pushing Order %d: %v", oldID, err)
}
}
}
// 4. Sync Payments
payments, err := s.repo.GetUnsyncedPayments()
if err == nil && len(payments) > 0 {
for _, p := range payments {
if p.TypePayID == 0 {
log.Printf("SKIP: Payment %d has invalid type_pay 0.", p.ID)
s.repo.MarkAsSynced(&models.Payment{}, p.ID)
continue
}
oldID := p.ID
payload := map[string]interface{}{
"value": p.Value,
"type_pay": p.TypePayID,
"comanda": p.ComandaID,
"description": p.Description,
"datetime": p.DateTime,
"uuid": p.UUID,
}
if p.ClientID != nil {
payload["client"] = *p.ClientID
}
endpoint := fmt.Sprintf("payments/%d", p.ID)
respBody, err := s.sendRequest("PATCH", endpoint, payload)
if err != nil && strings.Contains(err.Error(), "status: 404") {
respBody, err = s.sendRequest("POST", "payments", payload)
}
if err == nil {
var created models.Payment
json.Unmarshal(respBody, &created)
if created.ID != 0 && created.ID != oldID {
s.repo.SafeChangeID("payments", oldID, created.ID)
}
s.repo.MarkAsSynced(&models.Payment{}, created.ID)
log.Printf("SUCCESS: Pushed Payment %d (now %d) to cloud", oldID, created.ID)
} else {
log.Printf("ERROR pushing Payment %d: %v", oldID, err)
}
}
}
// 5. Sync Products
products, err := s.repo.GetUnsyncedProducts()
if err == nil && len(products) > 0 {
for _, p := range products {
oldID := p.ID
payload := map[string]interface{}{
"name": p.Name,
"description": p.Description,
"image": p.Image,
"price": p.Price,
"quantity": p.Quantity,
"category": p.CategoryID,
"cuisine": p.Cuisine,
"active": p.Active,
"uuid": p.UUID,
}
if p.UnitOfMeasure != nil {
payload["unit_of_measure"] = *p.UnitOfMeasure
}
endpoint := fmt.Sprintf("products/%d", p.ID)
respBody, err := s.sendMultipartRequest("PATCH", endpoint, payload)
if err != nil && strings.Contains(err.Error(), "status: 404") {
respBody, err = s.sendMultipartRequest("POST", "products", payload)
}
if err == nil {
var created models.Product
json.Unmarshal(respBody, &created)
if created.ID != 0 && created.ID != oldID {
s.repo.SafeChangeID("products", oldID, created.ID)
}
s.repo.MarkAsSynced(&models.Product{}, created.ID)
log.Printf("SUCCESS: Pushed Product %d (now %d) to cloud", oldID, created.ID)
} else {
log.Printf("ERROR pushing Product %d: %v", oldID, err)
}
}
}
}
func (s *Syncer) sendRequest(method, endpoint string, data interface{}) ([]byte, error) {
url := fmt.Sprintf("%s/api/v1/%s/", s.djangoURL, endpoint)
jsonData, _ := json.Marshal(data)
token := s.accessToken
if token == "" {
token = s.masterPass
}
req, _ := http.NewRequest(method, url, bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 401 || strings.Contains(string(respBody), "token_not_valid") {
log.Printf("Token expired during %s %s, refreshing...", method, endpoint)
if err := s.RefreshAccessToken(); err != nil {
return nil, fmt.Errorf("token refresh failed: %w", err)
}
return s.sendRequest(method, endpoint, data)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return respBody, nil
}
return nil, fmt.Errorf("django returned status: %d | Response: %s", resp.StatusCode, string(respBody))
}
func (s *Syncer) sendMultipartRequest(method, endpoint string, data map[string]interface{}) ([]byte, error) {
urlStr := fmt.Sprintf("%s/api/v1/%s/", s.djangoURL, endpoint)
var b bytes.Buffer
w := multipart.NewWriter(&b)
for key, val := range data {
if key == "image" {
imageStr, ok := val.(string)
if !ok || imageStr == "" {
continue // Skip empty image
}
// If it's an existing URL, don't re-upload it
if strings.HasPrefix(imageStr, "http") || strings.HasPrefix(imageStr, "/media/") || strings.HasPrefix(imageStr, "/images/") {
continue
}
// Base64
if strings.HasPrefix(imageStr, "data:image") {
parts := strings.Split(imageStr, ",")
if len(parts) == 2 {
decoded, err := base64.StdEncoding.DecodeString(parts[1])
if err == nil {
fw, _ := w.CreateFormFile(key, "upload.jpg")
fw.Write(decoded)
}
}
continue
}
// Local file
fileContent, err := os.ReadFile(imageStr)
if err == nil {
fw, _ := w.CreateFormFile(key, filepath.Base(imageStr))
fw.Write(fileContent)
}
} else {
// standard text field
w.WriteField(key, fmt.Sprintf("%v", val))
}
}
w.Close()
token := s.accessToken
if token == "" {
token = s.masterPass
}
req, _ := http.NewRequest(method, urlStr, &b)
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 401 || strings.Contains(string(respBody), "token_not_valid") {
log.Printf("Token expired during %s %s, refreshing...", method, urlStr)
if err := s.RefreshAccessToken(); err != nil {
return nil, fmt.Errorf("token refresh failed: %w", err)
}
return s.sendMultipartRequest(method, endpoint, data)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return respBody, nil
}
return nil, fmt.Errorf("django returned status: %d | Response: %s", resp.StatusCode, string(respBody))
}
func (s *Syncer) syncUsers() {
// Optional: logic for background sync of users if needed
}