package controllers import ( "fmt" "net/http" "strconv" "strings" "service-warungpos-go/config" "service-warungpos-go/models" "service-warungpos-go/services" "github.com/gin-gonic/gin" "gorm.io/gorm" "math/rand" ) // DTOs untuk Kategori & Produk type CategoryCreateRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description"` } type CategoryResponse struct { ID uint `json:"id"` CompanyID uint `json:"company_id"` Name string `json:"name"` Description string `json:"description"` CreatedAt string `json:"created_at"` } type ProductCreateRequest struct { Name string `json:"name" binding:"required"` SKU *string `json:"sku"` Price int `json:"price" binding:"required"` Stock int `json:"stock"` IsUnlimitedStock bool `json:"is_unlimited_stock"` CategoryID *uint `json:"category_id"` ImageURL string `json:"image_url"` } // ==================== IMAGE UPLOAD ==================== func UploadProductImage(c *gin.Context) { file, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": "File tidak ditemukan dalam request"}) return } // Validasi jenis file allowedTypes := map[string]bool{ "image/jpeg": true, "image/png": true, "image/webp": true, "image/gif": true, } contentType := file.Header.Get("Content-Type") if !allowedTypes[contentType] { c.JSON(http.StatusBadRequest, gin.H{"detail": "Hanya file gambar (JPG, PNG, WebP, GIF) yang diizinkan"}) return } // Validasi ukuran (maksimal 5MB) if file.Size > 5*1024*1024 { c.JSON(http.StatusBadRequest, gin.H{"detail": "Ukuran gambar maksimal 5MB"}) return } // Upload langsung ke Cloudinary secureURL, err := services.UploadToCloudinary(file) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Gagal upload gambar: %v", err)}) return } c.JSON(http.StatusOK, gin.H{ "image_url": secureURL, }) } // ==================== CATEGORY CONTROLLER ==================== func ListCategories(c *gin.Context) { userVal, _ := c.Get("user") user := userVal.(*models.User) var branchID *uint // Periksa query param branch_id if qBranchID := c.Query("branch_id"); qBranchID != "" { if val, err := strconv.ParseUint(qBranchID, 10, 32); err == nil { uVal := uint(val) branchID = &uVal } } // Jika kasir, paksa filter berdasarkan branch_id kasir if user.Role == "kasir" && user.BranchID != nil { branchID = user.BranchID } var allCategories []models.Category if err := config.DB.Select("id, company_id, name, description, created_at").Where("company_id = ?", *user.CompanyID).Find(&allCategories).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal mengambil data kategori"}) return } // Jika tidak ada filter branch_id, kembalikan semua kategori langsung if branchID == nil { c.JSON(http.StatusOK, allCategories) return } // Ambil daftar product_id yang tidak tersedia di cabang ini var unavailableProductIDs []uint config.DB.Model(&models.BranchProduct{}). Where("branch_id = ? AND is_available = ?", *branchID, false). Pluck("product_id", &unavailableProductIDs) // Cari kategori dari produk aktif yang tersedia di cabang ini var availableCategoryIDs []uint query := config.DB.Model(&models.Product{}). Where("company_id = ? AND is_active = ?", *user.CompanyID, true) if len(unavailableProductIDs) > 0 { query = query.Where("id NOT IN ?", unavailableProductIDs) } query.Pluck("distinct category_id", &availableCategoryIDs) // Filter allCategories agar hanya mengembalikan kategori yang memiliki produk tersedia di cabang catMap := make(map[uint]bool) for _, catID := range availableCategoryIDs { catMap[catID] = true } var resp []models.Category for _, cat := range allCategories { if catMap[cat.ID] { resp = append(resp, cat) } } c.JSON(http.StatusOK, resp) } func GetCategory(c *gin.Context) { userVal, _ := c.Get("user") user := userVal.(*models.User) catIDStr := c.Param("category_id") catID, err := strconv.Atoi(catIDStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": "ID kategori tidak valid"}) return } var category models.Category if err := config.DB.Select("id, company_id, name, description, created_at").Where("id = ? AND company_id = ?", catID, *user.CompanyID).First(&category).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"detail": "Kategori tidak ditemukan"}) return } c.JSON(http.StatusOK, category) } func CreateCategory(c *gin.Context) { userVal, _ := c.Get("user") user := userVal.(*models.User) var req CategoryCreateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } // Cek duplikasi nama kategori di perusahaan yang sama var count int64 config.DB.Model(&models.Category{}).Where("name = ? AND company_id = ?", req.Name, *user.CompanyID).Count(&count) if count > 0 { c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("Kategori '%s' sudah ada", req.Name)}) return } newCat := models.Category{ CompanyID: *user.CompanyID, Name: req.Name, Description: req.Description, } if err := config.DB.Create(&newCat).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyimpan kategori baru"}) return } c.JSON(http.StatusCreated, newCat) } func UpdateCategory(c *gin.Context) { userVal, _ := c.Get("user") user := userVal.(*models.User) catIDStr := c.Param("category_id") catID, err := strconv.Atoi(catIDStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": "ID kategori tidak valid"}) return } var req CategoryCreateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } var category models.Category if err := config.DB.Select("id, company_id, name, description").Where("id = ? AND company_id = ?", catID, *user.CompanyID).First(&category).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"detail": "Kategori tidak ditemukan"}) return } if req.Name != "" { var count int64 config.DB.Model(&models.Category{}).Where("name = ? AND company_id = ? AND id != ?", req.Name, *user.CompanyID, catID).Count(&count) if count > 0 { c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("Kategori '%s' sudah ada", req.Name)}) return } category.Name = req.Name } category.Description = req.Description if err := config.DB.Save(&category).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal meng-update kategori"}) return } c.JSON(http.StatusOK, category) } func DeleteCategory(c *gin.Context) { userVal, _ := c.Get("user") user := userVal.(*models.User) catIDStr := c.Param("category_id") catID, err := strconv.Atoi(catIDStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": "ID kategori tidak valid"}) return } var productCount int64 config.DB.Model(&models.Product{}).Where("category_id = ?", catID).Count(&productCount) if productCount > 0 { c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("Kategori masih memiliki %d produk. Pindahkan produk dulu.", productCount)}) return } var category models.Category if err := config.DB.Select("id, name").Where("id = ? AND company_id = ?", catID, *user.CompanyID).First(&category).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"detail": "Kategori tidak ditemukan"}) return } if err := config.DB.Delete(&category).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menghapus kategori"}) return } c.JSON(http.StatusOK, gin.H{ "message": fmt.Sprintf("Kategori '%s' berhasil dihapus", category.Name), }) } // ==================== PRODUCT CONTROLLER ==================== func ListProducts(c *gin.Context) { userVal, _ := c.Get("user") user := userVal.(*models.User) var branchID *uint // Cek filter branch_id query param (owner di POS) if qBranchID := c.Query("branch_id"); qBranchID != "" { if val, err := strconv.ParseUint(qBranchID, 10, 32); err == nil { uVal := uint(val) branchID = &uVal } } // Kasir terikat branch if user.Role == "kasir" && user.BranchID != nil { branchID = user.BranchID } categoryFilter := c.Query("category_id") activeOnlyStr := c.DefaultQuery("active_only", "true") activeOnly := activeOnlyStr == "true" searchQuery := c.Query("search") limitStr := c.Query("limit") skipStr := c.Query("skip") query := config.DB.Select("id, company_id, category_id, name, sku, price, stock, is_unlimited_stock, image_url, is_active, created_at, updated_at"). Preload("Category", func(db *gorm.DB) *gorm.DB { return db.Select("id, name") }). Where("company_id = ?", *user.CompanyID) if activeOnly { query = query.Where("is_active = ?", true) } if categoryFilter != "" { if catID, err := strconv.Atoi(categoryFilter); err == nil { query = query.Where("category_id = ?", catID) } } if searchQuery != "" { query = query.Where("LOWER(name) LIKE ? OR LOWER(sku) LIKE ?", "%"+strings.ToLower(searchQuery)+"%", "%"+strings.ToLower(searchQuery)+"%") } // Filter ketersediaan cabang (BranchProduct) di level database if branchID != nil { query = query.Where("id NOT IN (SELECT product_id FROM branch_products WHERE branch_id = ? AND is_available = ?)", *branchID, false) } // Terapkan pagination jika limit diisi if limitStr != "" { if limit, err := strconv.Atoi(limitStr); err == nil { query = query.Limit(limit) } } if skipStr != "" { if skip, err := strconv.Atoi(skipStr); err == nil { query = query.Offset(skip) } } products := []models.Product{} if err := query.Find(&products).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal mengambil data produk"}) return } c.JSON(http.StatusOK, products) } func GetProduct(c *gin.Context) { userVal, _ := c.Get("user") user := userVal.(*models.User) productIDStr := c.Param("product_id") productID, err := strconv.Atoi(productIDStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": "ID produk tidak valid"}) return } var product models.Product if err := config.DB.Select("id, company_id, category_id, name, sku, price, stock, is_unlimited_stock, image_url, is_active, created_at, updated_at"). Preload("Category", func(db *gorm.DB) *gorm.DB { return db.Select("id, name") }). Where("id = ? AND company_id = ?", productID, *user.CompanyID). First(&product).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"detail": "Produk tidak ditemukan"}) return } c.JSON(http.StatusOK, product) } func CreateProduct(c *gin.Context) { userVal, _ := c.Get("user") user := userVal.(*models.User) var req ProductCreateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } if req.SKU == nil || *req.SKU == "" { generated := fmt.Sprintf("SKU-%04X%04X", rand.Intn(65536), rand.Intn(65536)) req.SKU = &generated } // Verifikasi SKU jika diisi if req.SKU != nil && *req.SKU != "" { var count int64 config.DB.Model(&models.Product{}).Where("sku = ?", *req.SKU).Count(&count) if count > 0 { c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("Produk dengan SKU '%s' sudah ada", *req.SKU)}) return } } newProduct := models.Product{ CompanyID: *user.CompanyID, Name: req.Name, SKU: req.SKU, Price: req.Price, Stock: req.Stock, IsUnlimitedStock: req.IsUnlimitedStock, CategoryID: req.CategoryID, ImageURL: req.ImageURL, IsActive: true, } if err := config.DB.Create(&newProduct).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyimpan produk baru"}) return } if newProduct.CategoryID != nil { config.DB.Select("id, name").First(&newProduct.Category, *newProduct.CategoryID) } c.JSON(http.StatusCreated, newProduct) } func UpdateProduct(c *gin.Context) { userVal, _ := c.Get("user") user := userVal.(*models.User) productIDStr := c.Param("product_id") productID, err := strconv.Atoi(productIDStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": "ID produk tidak valid"}) return } var req ProductCreateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } if req.SKU != nil && *req.SKU == "" { req.SKU = nil } var product models.Product if err := config.DB.Where("id = ? AND company_id = ?", productID, *user.CompanyID).First(&product).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"detail": "Produk tidak ditemukan"}) return } // Verifikasi SKU jika berubah if req.SKU != nil && *req.SKU != "" { if product.SKU == nil || *product.SKU != *req.SKU { var count int64 config.DB.Model(&models.Product{}).Where("sku = ? AND id != ?", *req.SKU, productID).Count(&count) if count > 0 { c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("Produk dengan SKU '%s' sudah ada", *req.SKU)}) return } product.SKU = req.SKU } } else { product.SKU = nil } product.Name = req.Name product.Price = req.Price product.Stock = req.Stock product.IsUnlimitedStock = req.IsUnlimitedStock product.CategoryID = req.CategoryID product.ImageURL = req.ImageURL if err := config.DB.Save(&product).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal meng-update produk"}) return } if product.CategoryID != nil { config.DB.Select("id, name").First(&product.Category, *product.CategoryID) } c.JSON(http.StatusOK, product) } func DeleteProduct(c *gin.Context) { userVal, _ := c.Get("user") user := userVal.(*models.User) productIDStr := c.Param("product_id") productID, err := strconv.Atoi(productIDStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": "ID produk tidak valid"}) return } result := config.DB.Model(&models.Product{}). Where("id = ? AND company_id = ?", productID, *user.CompanyID). Update("is_active", false) if result.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menghapus produk"}) return } if result.RowsAffected == 0 { c.JSON(http.StatusNotFound, gin.H{"detail": "Produk tidak ditemukan"}) return } c.JSON(http.StatusOK, gin.H{ "message": "Produk berhasil dinonaktifkan", }) }