From 65c993abb51746a1e00f020f659d362633c7c35b Mon Sep 17 00:00:00 2001 From: weijuesen Date: Wed, 12 Aug 2026 18:03:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(server-go):=20=E8=80=97=E6=9D=90=E7=AE=A1?= =?UTF-8?q?=E7=90=86=EF=BC=88=E5=BA=93=E5=AD=98/=E9=A2=84=E8=AD=A6/?= =?UTF-8?q?=E9=87=87=E8=B4=AD=E5=BB=BA=E8=AE=AE=EF=BC=8C#16=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server-go/cmd/server/main.go | 1 + server-go/internal/database/db.go | 1 + server-go/internal/handler/consumable.go | 147 ++++++++++++++++++++ server-go/internal/model/consumable.go | 45 ++++++ server-go/internal/model/consumable_test.go | 44 ++++++ server-go/internal/model/permission_seed.go | 6 + 6 files changed, 244 insertions(+) create mode 100644 server-go/internal/handler/consumable.go create mode 100644 server-go/internal/model/consumable.go create mode 100644 server-go/internal/model/consumable_test.go diff --git a/server-go/cmd/server/main.go b/server-go/cmd/server/main.go index 42ea147..e2a215a 100644 --- a/server-go/cmd/server/main.go +++ b/server-go/cmd/server/main.go @@ -115,6 +115,7 @@ func main() { handler.RegisterWechatRoutes(api, db, wechatSvc) handler.RegisterWeatherRoutes(api, db, weatherSvc) handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages) + handler.RegisterConsumableRoutes(api, db) // 启动高发病天气预警定时任务(未配置时跳过) go startWeatherAlertLoop(db, weatherSvc, time.Duration(cfg.QWeatherIntervalMin)*time.Minute) diff --git a/server-go/internal/database/db.go b/server-go/internal/database/db.go index 1d097af..78654bf 100644 --- a/server-go/internal/database/db.go +++ b/server-go/internal/database/db.go @@ -33,6 +33,7 @@ func Init(cfg *config.Config) error { &model.WechatBinding{}, &model.WeatherAlert{}, &model.LampTest{}, &model.LampTestStep{}, + &model.Consumable{}, ); err != nil { slog.Warn("自动迁移有警告(可忽略)", "err", err) } diff --git a/server-go/internal/handler/consumable.go b/server-go/internal/handler/consumable.go new file mode 100644 index 0000000..1ca7471 --- /dev/null +++ b/server-go/internal/handler/consumable.go @@ -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) + } +} diff --git a/server-go/internal/model/consumable.go b/server-go/internal/model/consumable.go new file mode 100644 index 0000000..ab13ebf --- /dev/null +++ b/server-go/internal/model/consumable.go @@ -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) +} diff --git a/server-go/internal/model/consumable_test.go b/server-go/internal/model/consumable_test.go new file mode 100644 index 0000000..82565a9 --- /dev/null +++ b/server-go/internal/model/consumable_test.go @@ -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) + } +} diff --git a/server-go/internal/model/permission_seed.go b/server-go/internal/model/permission_seed.go index 04e0827..91ca691 100644 --- a/server-go/internal/model/permission_seed.go +++ b/server-go/internal/model/permission_seed.go @@ -37,6 +37,8 @@ var AllPermissions = []PermissionDef{ {"weather:read", "天气查看", "查看天气与高发病天气预警"}, {"lamp:read", "LAMP 检测查看", "查看 LAMP 检测任务单与结果"}, {"lamp:write", "LAMP 检测管理", "新建、编辑、录入 LAMP 检测结果"}, + {"consumable:read", "耗材查看", "查看耗材库存与预警"}, + {"consumable:write", "耗材管理", "新增、编辑、删除耗材"}, {"user:manage", "用户管理", "管理用户、角色和权限"}, {"audit:read", "审计查看", "查看审计日志"}, } @@ -53,6 +55,7 @@ var RolePermissionMap = map[string][]string{ "notification:read", "notification:write", "weather:read", "lamp:read", "lamp:write", + "consumable:read", "consumable:write", "user:manage", "audit:read", }, RoleOperator: { @@ -65,6 +68,7 @@ var RolePermissionMap = map[string][]string{ "notification:read", "notification:write", "weather:read", "lamp:read", "lamp:write", + "consumable:read", "consumable:write", }, RoleViewer: { "dashboard:view", "room:read", "device:read", @@ -75,6 +79,7 @@ var RolePermissionMap = map[string][]string{ "notification:read", "weather:read", "lamp:read", + "consumable:read", }, RoleFarmer: { "dashboard:view", "room:read", "device:read", "device:control", @@ -85,5 +90,6 @@ var RolePermissionMap = map[string][]string{ "notification:read", "notification:write", "weather:read", "lamp:read", + "consumable:read", }, }