feat(server-go): 耗材管理(库存/预警/采购建议,#16)
This commit is contained in:
@@ -115,6 +115,7 @@ func main() {
|
|||||||
handler.RegisterWechatRoutes(api, db, wechatSvc)
|
handler.RegisterWechatRoutes(api, db, wechatSvc)
|
||||||
handler.RegisterWeatherRoutes(api, db, weatherSvc)
|
handler.RegisterWeatherRoutes(api, db, weatherSvc)
|
||||||
handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
||||||
|
handler.RegisterConsumableRoutes(api, db)
|
||||||
|
|
||||||
// 启动高发病天气预警定时任务(未配置时跳过)
|
// 启动高发病天气预警定时任务(未配置时跳过)
|
||||||
go startWeatherAlertLoop(db, weatherSvc, time.Duration(cfg.QWeatherIntervalMin)*time.Minute)
|
go startWeatherAlertLoop(db, weatherSvc, time.Duration(cfg.QWeatherIntervalMin)*time.Minute)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ func Init(cfg *config.Config) error {
|
|||||||
&model.WechatBinding{},
|
&model.WechatBinding{},
|
||||||
&model.WeatherAlert{},
|
&model.WeatherAlert{},
|
||||||
&model.LampTest{}, &model.LampTestStep{},
|
&model.LampTest{}, &model.LampTestStep{},
|
||||||
|
&model.Consumable{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
slog.Warn("自动迁移有警告(可忽略)", "err", err)
|
slog.Warn("自动迁移有警告(可忽略)", "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"silk-server-go/internal/middleware"
|
||||||
|
"silk-server-go/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterConsumableRoutes 注册耗材路由
|
||||||
|
func RegisterConsumableRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||||
|
read := middleware.RequirePermission(db, "consumable:read")
|
||||||
|
write := middleware.RequirePermission(db, "consumable:write")
|
||||||
|
rg.GET("/consumables", read, listConsumables(db))
|
||||||
|
rg.POST("/consumables", write, createConsumable(db))
|
||||||
|
rg.PATCH("/consumables/:id", write, updateConsumable(db))
|
||||||
|
rg.DELETE("/consumables/:id", write, deleteConsumable(db))
|
||||||
|
rg.GET("/consumables/alerts", read, consumableAlerts(db))
|
||||||
|
rg.GET("/consumables/purchase-suggestions", read, purchaseSuggestions(db))
|
||||||
|
}
|
||||||
|
|
||||||
|
// listConsumables 耗材列表(category 过滤)
|
||||||
|
func listConsumables(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
q := db.Model(&model.Consumable{})
|
||||||
|
if cat := c.Query("category"); cat != "" {
|
||||||
|
q = q.Where("category = ?", cat)
|
||||||
|
}
|
||||||
|
var list []model.Consumable
|
||||||
|
q.Order("name ASC").Find(&list)
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createConsumable(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var item model.Consumable
|
||||||
|
if err := c.ShouldBindJSON(&item); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.ID = ""
|
||||||
|
if item.Name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "耗材名称不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if item.Category == "" {
|
||||||
|
item.Category = "other"
|
||||||
|
}
|
||||||
|
if err := db.Create(&item).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateConsumable(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var item model.Consumable
|
||||||
|
if db.Where("id = ?", id).First(&item).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "consumable not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates, err := bindUpdates(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
db.Model(&model.Consumable{}).Where("id = ?", id).Updates(updates)
|
||||||
|
}
|
||||||
|
db.Where("id = ?", id).First(&item)
|
||||||
|
c.JSON(http.StatusOK, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteConsumable(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var item model.Consumable
|
||||||
|
if db.Where("id = ?", id).First(&item).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "consumable not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Where("id = ?", id).Delete(&model.Consumable{})
|
||||||
|
c.JSON(http.StatusOK, gin.H{"id": id})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumableAlerts 低库存 + 临期/过期预警(30 天窗口)
|
||||||
|
func consumableAlerts(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var list []model.Consumable
|
||||||
|
db.Find(&list)
|
||||||
|
result := make([]gin.H, 0)
|
||||||
|
for _, item := range list {
|
||||||
|
alertType := ""
|
||||||
|
if item.LowStockAlert() {
|
||||||
|
alertType = "low_stock"
|
||||||
|
}
|
||||||
|
if item.ExpiringAlert(30) {
|
||||||
|
alertType = "expiring"
|
||||||
|
}
|
||||||
|
if alertType != "" {
|
||||||
|
result = append(result, gin.H{
|
||||||
|
"id": item.ID,
|
||||||
|
"name": item.Name,
|
||||||
|
"category": item.Category,
|
||||||
|
"quantity": item.Quantity,
|
||||||
|
"unit": item.Unit,
|
||||||
|
"minQuantity": item.MinQuantity,
|
||||||
|
"expiryDate": item.ExpiryDate,
|
||||||
|
"alertType": alertType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// purchaseSuggestions 低于安全阈值的采购建议
|
||||||
|
func purchaseSuggestions(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var list []model.Consumable
|
||||||
|
db.Find(&list)
|
||||||
|
result := make([]gin.H, 0)
|
||||||
|
for _, item := range list {
|
||||||
|
if s := item.PurchaseSuggestion(); s > 0 {
|
||||||
|
result = append(result, gin.H{
|
||||||
|
"id": item.ID,
|
||||||
|
"name": item.Name,
|
||||||
|
"category": item.Category,
|
||||||
|
"quantity": item.Quantity,
|
||||||
|
"unit": item.Unit,
|
||||||
|
"minQuantity": item.MinQuantity,
|
||||||
|
"suggest": s,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Consumable 耗材
|
||||||
|
type Consumable struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||||
|
Name string `gorm:"size:64" json:"name"`
|
||||||
|
Category string `gorm:"size:32;index" json:"category"` // lamp_reagent/lamp_consumable/disinfectant/other
|
||||||
|
Spec *string `gorm:"size:128" json:"spec,omitempty"`
|
||||||
|
Quantity float64 `gorm:"type:float" json:"quantity"`
|
||||||
|
Unit *string `gorm:"size:32" json:"unit,omitempty"`
|
||||||
|
MinQuantity float64 `gorm:"column:min_quantity;type:float" json:"minQuantity"`
|
||||||
|
ExpiryDate *time.Time `gorm:"column:expiry_date;type:timestamptz" json:"expiryDate,omitempty"`
|
||||||
|
Supplier *string `gorm:"size:128" json:"supplier,omitempty"`
|
||||||
|
Note *string `gorm:"type:text" json:"note,omitempty"`
|
||||||
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Consumable) TableName() string { return "consumables" }
|
||||||
|
|
||||||
|
// LowStockAlert 库存低于安全阈值
|
||||||
|
func (c Consumable) LowStockAlert() bool {
|
||||||
|
return c.Quantity < c.MinQuantity
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpiringAlert 效期在 days 天内到期(含已过期)
|
||||||
|
func (c Consumable) ExpiringAlert(days int) bool {
|
||||||
|
if c.ExpiryDate == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !c.ExpiryDate.After(time.Now().Add(time.Duration(days) * 24 * time.Hour))
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurchaseSuggestion 建议采购量(低于安全阈值时补足差额,向上取整)
|
||||||
|
func (c Consumable) PurchaseSuggestion() float64 {
|
||||||
|
if !c.LowStockAlert() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return math.Ceil(c.MinQuantity - c.Quantity)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConsumableLowStockAlert(t *testing.T) {
|
||||||
|
c := Consumable{Quantity: 5, MinQuantity: 10}
|
||||||
|
if !c.LowStockAlert() {
|
||||||
|
t.Error("库存低于安全阈值应预警")
|
||||||
|
}
|
||||||
|
c2 := Consumable{Quantity: 10, MinQuantity: 10}
|
||||||
|
if c2.LowStockAlert() {
|
||||||
|
t.Error("库存等于安全阈值不应预警")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumableExpiringAlert(t *testing.T) {
|
||||||
|
soon := time.Now().Add(10 * 24 * time.Hour)
|
||||||
|
if !(Consumable{ExpiryDate: &soon}).ExpiringAlert(30) {
|
||||||
|
t.Error("30 天内到期应预警")
|
||||||
|
}
|
||||||
|
past := time.Now().Add(-24 * time.Hour)
|
||||||
|
if !(Consumable{ExpiryDate: &past}).ExpiringAlert(30) {
|
||||||
|
t.Error("已过期应预警")
|
||||||
|
}
|
||||||
|
far := time.Now().Add(60 * 24 * time.Hour)
|
||||||
|
if (Consumable{ExpiryDate: &far}).ExpiringAlert(30) {
|
||||||
|
t.Error("60 天后到期不应预警(30 天窗口)")
|
||||||
|
}
|
||||||
|
if (Consumable{}).ExpiringAlert(30) {
|
||||||
|
t.Error("无效期不应预警")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurchaseSuggestion(t *testing.T) {
|
||||||
|
if s := (Consumable{Quantity: 3, MinQuantity: 10}).PurchaseSuggestion(); s != 7 {
|
||||||
|
t.Errorf("建议采购量 = %v, want 7", s)
|
||||||
|
}
|
||||||
|
if s := (Consumable{Quantity: 10, MinQuantity: 10}).PurchaseSuggestion(); s != 0 {
|
||||||
|
t.Errorf("不缺货建议采购量 = %v, want 0", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ var AllPermissions = []PermissionDef{
|
|||||||
{"weather:read", "天气查看", "查看天气与高发病天气预警"},
|
{"weather:read", "天气查看", "查看天气与高发病天气预警"},
|
||||||
{"lamp:read", "LAMP 检测查看", "查看 LAMP 检测任务单与结果"},
|
{"lamp:read", "LAMP 检测查看", "查看 LAMP 检测任务单与结果"},
|
||||||
{"lamp:write", "LAMP 检测管理", "新建、编辑、录入 LAMP 检测结果"},
|
{"lamp:write", "LAMP 检测管理", "新建、编辑、录入 LAMP 检测结果"},
|
||||||
|
{"consumable:read", "耗材查看", "查看耗材库存与预警"},
|
||||||
|
{"consumable:write", "耗材管理", "新增、编辑、删除耗材"},
|
||||||
{"user:manage", "用户管理", "管理用户、角色和权限"},
|
{"user:manage", "用户管理", "管理用户、角色和权限"},
|
||||||
{"audit:read", "审计查看", "查看审计日志"},
|
{"audit:read", "审计查看", "查看审计日志"},
|
||||||
}
|
}
|
||||||
@@ -53,6 +55,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"notification:read", "notification:write",
|
"notification:read", "notification:write",
|
||||||
"weather:read",
|
"weather:read",
|
||||||
"lamp:read", "lamp:write",
|
"lamp:read", "lamp:write",
|
||||||
|
"consumable:read", "consumable:write",
|
||||||
"user:manage", "audit:read",
|
"user:manage", "audit:read",
|
||||||
},
|
},
|
||||||
RoleOperator: {
|
RoleOperator: {
|
||||||
@@ -65,6 +68,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"notification:read", "notification:write",
|
"notification:read", "notification:write",
|
||||||
"weather:read",
|
"weather:read",
|
||||||
"lamp:read", "lamp:write",
|
"lamp:read", "lamp:write",
|
||||||
|
"consumable:read", "consumable:write",
|
||||||
},
|
},
|
||||||
RoleViewer: {
|
RoleViewer: {
|
||||||
"dashboard:view", "room:read", "device:read",
|
"dashboard:view", "room:read", "device:read",
|
||||||
@@ -75,6 +79,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"notification:read",
|
"notification:read",
|
||||||
"weather:read",
|
"weather:read",
|
||||||
"lamp:read",
|
"lamp:read",
|
||||||
|
"consumable:read",
|
||||||
},
|
},
|
||||||
RoleFarmer: {
|
RoleFarmer: {
|
||||||
"dashboard:view", "room:read", "device:read", "device:control",
|
"dashboard:view", "room:read", "device:read", "device:control",
|
||||||
@@ -85,5 +90,6 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"notification:read", "notification:write",
|
"notification:read", "notification:write",
|
||||||
"weather:read",
|
"weather:read",
|
||||||
"lamp:read",
|
"lamp:read",
|
||||||
|
"consumable:read",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user