diff --git a/backend/internal/handler/inventory.go b/backend/internal/handler/inventory.go index bd1b024..80ac350 100644 --- a/backend/internal/handler/inventory.go +++ b/backend/internal/handler/inventory.go @@ -99,6 +99,11 @@ func (h *InventoryHandler) List(c *gin.Context) { args = append(args, strings.TrimSpace(s)) } } + // 按商品过滤(商品详情:当前库存 / 货值 / 各仓库分布) + if productIDStr := c.Query("product_id"); productIDStr != "" { + baseWhere += " AND inv.product_id = ?" + args = append(args, productIDStr) + } // Count query countSQL := ` diff --git a/backend/internal/handler/inventory_test.go b/backend/internal/handler/inventory_test.go index ebf07b4..932e894 100644 --- a/backend/internal/handler/inventory_test.go +++ b/backend/internal/handler/inventory_test.go @@ -59,6 +59,28 @@ func TestInventoryHandler_List_FilterByWarehouse(t *testing.T) { assert.Equal(t, float64(1), resp["total"].(float64)) } +func TestInventoryHandler_List_FilterByProduct(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "INV_PID") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + wh1 := testutil.CreateTestWarehouse(db, shop.ID, "W1") + wh2 := testutil.CreateTestWarehouse(db, shop.ID, "W2") + product1 := testutil.CreateTestProduct(db, shop.ID, "Beer1") + product2 := testutil.CreateTestProduct(db, shop.ID, "Beer2") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + + // product1 在两个仓库各有库存;product2 一条 + db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh1.ID, ProductID: &product1.ID, Quantity: 10}) + db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh2.ID, ProductID: &product1.ID, Quantity: 20}) + db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh1.ID, ProductID: &product2.ID, Quantity: 5}) + + // 按商品过滤 → 只返回 product1 的两条(商品详情:各仓库分布) + w := makeRequest(r, "GET", fmt.Sprintf("/api/v1/inventory?product_id=%d&page_size=100", product1.ID), token, nil) + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, float64(2), parseResponse(w)["total"].(float64)) +} + func TestInventoryHandler_List_InStockOnly(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "INV003") diff --git a/backend/internal/handler/product_option.go b/backend/internal/handler/product_option.go index 788d228..3deec74 100644 --- a/backend/internal/handler/product_option.go +++ b/backend/internal/handler/product_option.go @@ -220,3 +220,68 @@ func (h *ProductOptionHandler) DeleteSpec(c *gin.Context) { h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductSpecOption{}) c.JSON(http.StatusOK, gin.H{"message": "deleted"}) } + +// ── 香型 / 分类(ProductCategory)字典 CRUD ────────────────────────────── + +func (h *ProductOptionHandler) ListCategories(c *gin.Context) { + shopID := middleware.GetShopID(c) + items := make([]model.ProductCategory, 0) + q := h.db.Where("shop_id = ?", shopID) + if kw := strings.TrimSpace(c.Query("keyword")); kw != "" { + q = q.Where("name LIKE ?", "%"+kw+"%") + } + q.Order("sort_order ASC, id ASC").Find(&items) + util.RespondSuccess(c, items) +} + +func (h *ProductOptionHandler) CreateCategory(c *gin.Context) { + shopID := middleware.GetShopID(c) + var req struct { + Name string `json:"name" binding:"required"` + ParentID *uint64 `json:"parent_id"` + SortOrder int `json:"sort_order"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + item := model.ProductCategory{ + TenantBase: model.TenantBase{ShopID: shopID}, + Name: req.Name, + ParentID: req.ParentID, + SortOrder: req.SortOrder, + } + if err := h.db.Create(&item).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + util.RespondCreated(c, item) +} + +func (h *ProductOptionHandler) UpdateCategory(c *gin.Context) { + shopID := middleware.GetShopID(c) + var req struct { + Name string `json:"name" binding:"required"` + ParentID *uint64 `json:"parent_id"` + SortOrder int `json:"sort_order"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var item model.ProductCategory + if err := h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).First(&item).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{ + "name": req.Name, "parent_id": req.ParentID, "sort_order": req.SortOrder, + }) + util.RespondSuccess(c, item) +} + +func (h *ProductOptionHandler) DeleteCategory(c *gin.Context) { + shopID := middleware.GetShopID(c) + h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductCategory{}) + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} diff --git a/backend/internal/handler/product_option_test.go b/backend/internal/handler/product_option_test.go index ef5328f..150605d 100644 --- a/backend/internal/handler/product_option_test.go +++ b/backend/internal/handler/product_option_test.go @@ -1,6 +1,7 @@ package handler import ( + "fmt" "net/http" "testing" @@ -84,3 +85,73 @@ func TestProductOptionHandler_ListNamesIsolation(t *testing.T) { data := parseResponse(w)["data"].([]interface{}) assert.Len(t, data, 0) } + +// setupCategoryRouter 注册 香型/分类 字典 CRUD 路由(含 JWT)。 +func setupCategoryRouter(db *gorm.DB) *gin.Engine { + h := NewProductOptionHandler(db) + r := gin.New() + r.Use(gin.Recovery()) + api := r.Group("/api/v1") + api.Use(middleware.JWT(db)) + cats := api.Group("/product-options/categories") + cats.GET("", h.ListCategories) + cats.POST("", h.CreateCategory) + cats.PUT("/:id", h.UpdateCategory) + cats.DELETE("/:id", h.DeleteCategory) + return r +} + +func TestProductOptionHandler_CategoryCRUD(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "CAT001") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupCategoryRouter(db) + + // 1. 创建 + w := makeRequest(r, "POST", "/api/v1/product-options/categories", token, + map[string]interface{}{"name": "酱香"}) + assert.Equal(t, http.StatusCreated, w.Code) + id := parseResponse(w)["data"].(map[string]interface{})["id"] + + // 2. 列表命中 + w = makeRequest(r, "GET", "/api/v1/product-options/categories", token, nil) + assert.Equal(t, http.StatusOK, w.Code) + data := parseResponse(w)["data"].([]interface{}) + assert.Len(t, data, 1) + assert.Equal(t, "酱香", data[0].(map[string]interface{})["name"]) + + // 3. 更新 + w = makeRequest(r, "PUT", + fmt.Sprintf("/api/v1/product-options/categories/%v", id), token, + map[string]interface{}{"name": "浓香"}) + assert.Equal(t, http.StatusOK, w.Code) + w = makeRequest(r, "GET", "/api/v1/product-options/categories?keyword=浓", token, nil) + data = parseResponse(w)["data"].([]interface{}) + assert.Len(t, data, 1) + + // 4. 删除 + w = makeRequest(r, "DELETE", + fmt.Sprintf("/api/v1/product-options/categories/%v", id), token, nil) + assert.Equal(t, http.StatusOK, w.Code) + w = makeRequest(r, "GET", "/api/v1/product-options/categories", token, nil) + assert.Len(t, parseResponse(w)["data"].([]interface{}), 0) +} + +func TestProductOptionHandler_CategoryIsolation(t *testing.T) { + db := testutil.SetupTestDB() + shopA := testutil.CreateTestShop(db, "CAT_A") + userA := testutil.CreateTestUser(db, shopA.ID, "adminA", "pass", "admin") + tokenA := getAuthToken(userA.ID, shopA.ID, "admin") + shopB := testutil.CreateTestShop(db, "CAT_B") + r := setupCategoryRouter(db) + + db.Create(&model.ProductCategory{ + TenantBase: model.TenantBase{ShopID: shopB.ID}, Name: "他店香型", + }) + + // A 店查不到 B 店香型(shop_id 隔离) + w := makeRequest(r, "GET", "/api/v1/product-options/categories?keyword=他店", tokenA, nil) + assert.Equal(t, http.StatusOK, w.Code) + assert.Len(t, parseResponse(w)["data"].([]interface{}), 0) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index d7a1923..1ec54fb 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -263,6 +263,12 @@ func Setup(r *gin.Engine, db *gorm.DB) { opts.PUT("/specs/:id", productOptH.UpdateSpec) opts.DELETE("/specs/:id", productOptH.DeleteSpec) + // 香型 / 分类字典(ProductCategory) + opts.GET("/categories", productOptH.ListCategories) + opts.POST("/categories", productOptH.CreateCategory) + opts.PUT("/categories/:id", productOptH.UpdateCategory) + opts.DELETE("/categories/:id", productOptH.DeleteCategory) + // 商品属性字典(产地/保质期/储存方式/描述文档) opts.GET("/origins", productAttrH.ListOrigins) opts.POST("/origins", productAttrH.CreateOrigin)