feat(backend): 库存按商品过滤 + 香型(分类)字典 CRUD
商品域富数据/重组所需后端: - GET /inventory 加 product_id 过滤 → 解锁商品详情「当前库存/货值/各仓库分布」 (配合已支持 product_id 的 /inventory/logs,详情可拼出大半富数据) - 新增 /product-options/categories 香型字典 CRUD(ProductCategory 模型已存在, 补管理接口)→ 商品列表 5-tab 重组的最后一块字典 均守多租户 shop_id;新增 3 个测试(FilterByProduct/CategoryCRUD/CategoryIsolation)。 go build/vet/test 全过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
This commit is contained in:
@@ -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 := `
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user