diff --git a/backend/internal/handler/product.go b/backend/internal/handler/product.go index dc9f82f..1e87c27 100644 --- a/backend/internal/handler/product.go +++ b/backend/internal/handler/product.go @@ -233,6 +233,39 @@ func (h *ProductHandler) Detail(c *gin.Context) { util.RespondSuccess(c, product) } +// PriceHistory GET /api/v1/products/:id/price-history +// 进价变更历史:从已审核入库单的明细单价按时间派生(零额外表)。 +// 返回按日期倒序的 {date, price} 点;前端据相邻点算涨跌。 +func (h *ProductHandler) PriceHistory(c *gin.Context) { + shopID := middleware.GetShopID(c) + id := c.Param("id") + + type pricePoint struct { + Date string `json:"date"` + Price float64 `json:"price"` + } + points := make([]pricePoint, 0) + // 守多租户:order 的 shop_id 必须等于当前店;只取已审核单的正单价。 + h.db.Raw(` + SELECT o.order_date AS date, sii.unit_price AS price + FROM stock_in_items sii + JOIN stock_in_orders o ON o.id = sii.order_id + WHERE sii.product_id = ? AND o.shop_id = ? AND o.status = 'approved' + AND sii.unit_price > 0 + ORDER BY o.order_date DESC, sii.id DESC + LIMIT 20`, id, shopID).Scan(&points) + + // 去掉相邻重复价(只保留价格发生变化的点) + deduped := make([]pricePoint, 0, len(points)) + for i, p := range points { + if i > 0 && points[i-1].Price == p.Price { + continue + } + deduped = append(deduped, p) + } + util.RespondSuccess(c, deduped) +} + // QRCode GET /api/v1/products/:id/qrcode func (h *ProductHandler) QRCode(c *gin.Context) { shopID := middleware.GetShopID(c) diff --git a/backend/internal/handler/product_test.go b/backend/internal/handler/product_test.go index 4e04df4..8cb9508 100644 --- a/backend/internal/handler/product_test.go +++ b/backend/internal/handler/product_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -266,3 +267,41 @@ func TestProductHandler_UniqueShopCode(t *testing.T) { require.Error(t, err) assert.True(t, errors.Is(err, gorm.ErrDuplicatedKey)) } + +func TestProductHandler_PriceHistory(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "PH001") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + wh := testutil.CreateTestWarehouse(db, shop.ID, "W") + product := testutil.CreateTestProduct(db, shop.ID, "Maotai") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + + mkOrder := func(no string, daysAgo int, price float64, status string) { + o := &model.StockInOrder{ + TenantBase: model.TenantBase{ShopID: shop.ID}, + OrderNo: no, + WarehouseID: wh.ID, + OperatorID: user.ID, + Status: status, + OrderDate: model.Date{Time: time.Now().AddDate(0, 0, -daysAgo)}, + } + require.NoError(t, db.Create(o).Error) + require.NoError(t, db.Create(&model.StockInItem{ + OrderID: o.ID, ShopID: shop.ID, ProductID: product.ID, + UnitPrice: price, Quantity: 1, + }).Error) + } + mkOrder("RK-PH-1", 60, 2580, "approved") + mkOrder("RK-PH-2", 30, 2620, "approved") + mkOrder("RK-PH-3", 5, 2680, "approved") + mkOrder("RK-PH-4", 1, 9999, "draft") // 草稿不计入 + + w := makeRequest(r, "GET", + fmt.Sprintf("/api/v1/products/%d/price-history", product.ID), token, nil) + assert.Equal(t, http.StatusOK, w.Code) + data := parseResponse(w)["data"].([]interface{}) + assert.Len(t, data, 3) // 三个已审核不同价;草稿排除 + // 倒序:最新价在前 + assert.Equal(t, float64(2680), data[0].(map[string]interface{})["price"]) +} diff --git a/backend/internal/handler/testhelper_test.go b/backend/internal/handler/testhelper_test.go index 1a03065..1363b5b 100644 --- a/backend/internal/handler/testhelper_test.go +++ b/backend/internal/handler/testhelper_test.go @@ -38,6 +38,7 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine { products := api.Group("/products") products.GET("", productH.List) products.POST("", productH.Create) + products.GET("/:id/price-history", productH.PriceHistory) products.PUT("/:id", productH.Update) products.DELETE("/:id", productH.Delete) diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 1ec54fb..53ff755 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -117,6 +117,7 @@ func Setup(r *gin.Engine, db *gorm.DB) { products.POST("", productH.Create) products.POST("/find-or-create", productH.FindOrCreate) products.GET("/:id/detail", productH.Detail) + products.GET("/:id/price-history", productH.PriceHistory) products.GET("/:id/qrcode", productH.QRCode) products.PUT("/:id", productH.Update) products.DELETE("/:id", productH.Delete)