diff --git a/controllers/gateway.go b/controllers/gateway.go index 5d4db5d..4a7daae 100644 --- a/controllers/gateway.go +++ b/controllers/gateway.go @@ -1,6 +1,7 @@ package controllers import ( + "encoding/json" "errors" "hr_receiver/config" "hr_receiver/models" @@ -122,6 +123,10 @@ func (gc *GatewayAdminController) Create(c *gin.Context) { writeError(c, http.StatusInternalServerError, "保存网关失败") return } + if err := gc.syncGatewayInventory(record); err != nil { + writeError(c, http.StatusInternalServerError, err.Error()) + return + } writeSuccess(c, http.StatusCreated, "创建成功", record) } @@ -176,6 +181,10 @@ func (gc *GatewayAdminController) Update(c *gin.Context) { writeError(c, http.StatusInternalServerError, "更新网关失败") return } + if err := gc.syncGatewayInventory(record); err != nil { + writeError(c, http.StatusInternalServerError, err.Error()) + return + } writeSuccess(c, http.StatusOK, "更新成功", record) } @@ -198,6 +207,10 @@ func (gc *GatewayAdminController) Delete(c *gin.Context) { writeError(c, http.StatusInternalServerError, "删除网关失败") return } + if err := gc.deleteGatewayInventory(record); err != nil { + writeError(c, http.StatusInternalServerError, err.Error()) + return + } writeSuccess(c, http.StatusOK, "删除成功", nil) } @@ -232,6 +245,76 @@ func (gc *GatewayAdminController) findGatewayProjectType(projectTypeCode string) return projectType, nil } +func (gc *GatewayAdminController) syncGatewayInventory(record models.Gateway) error { + mac := strings.ToUpper(strings.TrimSpace(record.MAC)) + if mac == "" { + return nil + } + + parameterValuesBytes, err := json.Marshal(map[string]interface{}{ + "mac": mac, + "location": strings.TrimSpace(record.Location), + }) + if err != nil { + return err + } + + status := models.ProductInventoryStatusInStock + if record.IsSold { + status = models.ProductInventoryStatusSold + } + + serialNumber := mac + regionID := record.RegionID + + var inventory models.ProductInventory + err = gc.DB.Where("source_type = ? AND source_ref = ?", "gateway", mac).First(&inventory).Error + switch { + case err == nil: + inventory.ProductCode = "collection_gateway" + inventory.ProjectTypeCode = strings.TrimSpace(strings.ToLower(record.ProjectType)) + inventory.SerialNumber = &serialNumber + inventory.AssetName = strings.TrimSpace(record.Name) + inventory.Status = status + inventory.RegionID = ®ionID + inventory.StorageLocation = strings.TrimSpace(record.Location) + inventory.ParameterValues = string(parameterValuesBytes) + inventory.SourceType = "gateway" + inventory.SourceRef = mac + if record.IsSold { + inventory.SoldAt = record.SoldAt + } else { + inventory.SoldAt = nil + } + return gc.DB.Save(&inventory).Error + case errors.Is(err, gorm.ErrRecordNotFound): + inventory = models.ProductInventory{ + ProductCode: "collection_gateway", + ProjectTypeCode: strings.TrimSpace(strings.ToLower(record.ProjectType)), + SerialNumber: &serialNumber, + AssetName: strings.TrimSpace(record.Name), + Status: status, + RegionID: ®ionID, + StorageLocation: strings.TrimSpace(record.Location), + SoldAt: record.SoldAt, + ParameterValues: string(parameterValuesBytes), + SourceType: "gateway", + SourceRef: mac, + } + return gc.DB.Create(&inventory).Error + default: + return err + } +} + +func (gc *GatewayAdminController) deleteGatewayInventory(record models.Gateway) error { + mac := strings.ToUpper(strings.TrimSpace(record.MAC)) + if mac == "" { + return nil + } + return gc.DB.Where("source_type = ? AND source_ref = ?", "gateway", mac).Delete(&models.ProductInventory{}).Error +} + // 辅助方法:验证输入数据 func validateGatewayPayload(payload gatewayPayload) error { mac := strings.TrimSpace(payload.MAC) diff --git a/controllers/product_definition_admin.go b/controllers/product_definition_admin.go new file mode 100644 index 0000000..708da87 --- /dev/null +++ b/controllers/product_definition_admin.go @@ -0,0 +1,199 @@ +package controllers + +import ( + "encoding/json" + "errors" + "hr_receiver/config" + "hr_receiver/models" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type ProductDefinitionAdminController struct { + DB *gorm.DB +} + +type productDefinitionPayload struct { + Code string `json:"code"` + Name string `json:"name"` + Category string `json:"category"` + Description string `json:"description"` + ParameterSchema string `json:"parameterSchema"` + TrackSerialNumber bool `json:"trackSerialNumber"` + IsActive bool `json:"isActive"` + Sort int `json:"sort"` +} + +func NewProductDefinitionAdminController() *ProductDefinitionAdminController { + return &ProductDefinitionAdminController{DB: config.DB} +} + +func (pc *ProductDefinitionAdminController) List(c *gin.Context) { + var items []models.ProductDefinition + query := pc.DB.Model(&models.ProductDefinition{}).Order("sort ASC, id ASC") + + if keyword := strings.TrimSpace(c.Query("keyword")); keyword != "" { + likeValue := "%" + keyword + "%" + query = query.Where("code LIKE ? OR name LIKE ? OR description LIKE ?", likeValue, likeValue, likeValue) + } + if category := strings.TrimSpace(c.Query("category")); category != "" { + query = query.Where("category = ?", strings.ToLower(category)) + } + if isActiveStr := strings.TrimSpace(c.Query("isActive")); isActiveStr != "" { + isActive, err := strconv.ParseBool(isActiveStr) + if err != nil { + writeError(c, http.StatusBadRequest, "invalid isActive") + return + } + query = query.Where("is_active = ?", isActive) + } + + if err := query.Find(&items).Error; err != nil { + writeError(c, http.StatusInternalServerError, "failed to query product definitions") + return + } + writeSuccess(c, http.StatusOK, "query success", items) +} + +func (pc *ProductDefinitionAdminController) Create(c *gin.Context) { + var payload productDefinitionPayload + if err := c.ShouldBindJSON(&payload); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + if err := validateProductDefinitionPayload(payload); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + + record := models.ProductDefinition{ + Code: payload.Code, + Name: payload.Name, + Category: payload.Category, + Description: payload.Description, + ParameterSchema: payload.ParameterSchema, + TrackSerialNumber: payload.TrackSerialNumber, + IsActive: payload.IsActive, + Sort: payload.Sort, + } + if err := pc.DB.Create(&record).Error; err != nil { + writeProductDefinitionDBError(c, err) + return + } + writeSuccess(c, http.StatusCreated, "create success", record) +} + +func (pc *ProductDefinitionAdminController) Update(c *gin.Context) { + record, err := pc.findByID(c.Param("id")) + if err != nil { + respondProductDefinitionLookupError(c, err) + return + } + + var payload productDefinitionPayload + if err := c.ShouldBindJSON(&payload); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + if err := validateProductDefinitionPayload(payload); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + + record.Code = payload.Code + record.Name = payload.Name + record.Category = payload.Category + record.Description = payload.Description + record.ParameterSchema = payload.ParameterSchema + record.TrackSerialNumber = payload.TrackSerialNumber + record.IsActive = payload.IsActive + record.Sort = payload.Sort + + if err := pc.DB.Save(&record).Error; err != nil { + writeProductDefinitionDBError(c, err) + return + } + writeSuccess(c, http.StatusOK, "update success", record) +} + +func (pc *ProductDefinitionAdminController) Delete(c *gin.Context) { + record, err := pc.findByID(c.Param("id")) + if err != nil { + respondProductDefinitionLookupError(c, err) + return + } + + var inventoryCount int64 + if err := pc.DB.Model(&models.ProductInventory{}).Where("product_code = ?", record.Code).Count(&inventoryCount).Error; err != nil { + writeError(c, http.StatusInternalServerError, "failed to check inventory references") + return + } + if inventoryCount > 0 { + writeError(c, http.StatusConflict, "current product definition is referenced by inventory") + return + } + + var templateCount int64 + if err := pc.DB.Model(&models.ProjectProductTemplate{}).Where("product_code = ?", record.Code).Count(&templateCount).Error; err != nil { + writeError(c, http.StatusInternalServerError, "failed to check project template references") + return + } + if templateCount > 0 { + writeError(c, http.StatusConflict, "current product definition is referenced by project templates") + return + } + + if err := pc.DB.Delete(&record).Error; err != nil { + writeError(c, http.StatusInternalServerError, "failed to delete product definition") + return + } + writeSuccess(c, http.StatusOK, "delete success", nil) +} + +func (pc *ProductDefinitionAdminController) findByID(id string) (models.ProductDefinition, error) { + var record models.ProductDefinition + numericID, err := strconv.ParseUint(strings.TrimSpace(id), 10, 64) + if err != nil { + return record, gorm.ErrRecordNotFound + } + if err := pc.DB.First(&record, numericID).Error; err != nil { + return record, err + } + return record, nil +} + +func validateProductDefinitionPayload(payload productDefinitionPayload) error { + if strings.TrimSpace(payload.Code) == "" { + return errors.New("code is required") + } + if strings.TrimSpace(payload.Name) == "" { + return errors.New("name is required") + } + if strings.TrimSpace(payload.Category) == "" { + return errors.New("category is required") + } + if strings.TrimSpace(payload.ParameterSchema) != "" && !json.Valid([]byte(strings.TrimSpace(payload.ParameterSchema))) { + return errors.New("parameterSchema must be valid JSON") + } + return nil +} + +func respondProductDefinitionLookupError(c *gin.Context, err error) { + if errors.Is(err, gorm.ErrRecordNotFound) { + writeError(c, http.StatusNotFound, "product definition not found") + return + } + writeError(c, http.StatusInternalServerError, "failed to query product definition") +} + +func writeProductDefinitionDBError(c *gin.Context, err error) { + if strings.Contains(strings.ToLower(err.Error()), "unique") { + writeError(c, http.StatusConflict, "product code already exists") + return + } + writeError(c, http.StatusInternalServerError, "failed to persist product definition") +} diff --git a/controllers/product_inventory_admin.go b/controllers/product_inventory_admin.go new file mode 100644 index 0000000..4927034 --- /dev/null +++ b/controllers/product_inventory_admin.go @@ -0,0 +1,256 @@ +package controllers + +import ( + "encoding/json" + "errors" + "hr_receiver/config" + "hr_receiver/models" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type ProductInventoryAdminController struct { + DB *gorm.DB +} + +type productInventoryPayload struct { + ProductCode string `json:"productCode"` + ProjectTypeCode string `json:"projectTypeCode"` + SuiteCode string `json:"suiteCode"` + SerialNumber *string `json:"serialNumber"` + AssetName string `json:"assetName"` + Status string `json:"status"` + RegionID *uint32 `json:"regionId"` + StorageLocation string `json:"storageLocation"` + SoldTargetType string `json:"soldTargetType"` + SoldTargetName string `json:"soldTargetName"` + SoldTo string `json:"soldTo"` + SoldAt *time.Time `json:"soldAt"` + ParameterValues string `json:"parameterValues"` + Notes string `json:"notes"` +} + +func NewProductInventoryAdminController() *ProductInventoryAdminController { + return &ProductInventoryAdminController{DB: config.DB} +} + +func (pc *ProductInventoryAdminController) List(c *gin.Context) { + var items []models.ProductInventory + query := pc.DB.Model(&models.ProductInventory{}).Order("updated_at DESC, id DESC") + + if keyword := strings.TrimSpace(c.Query("keyword")); keyword != "" { + likeValue := "%" + keyword + "%" + query = query.Where("asset_name LIKE ? OR serial_number LIKE ? OR sold_to LIKE ? OR sold_target_name LIKE ? OR suite_code LIKE ? OR notes LIKE ?", likeValue, likeValue, likeValue, likeValue, likeValue, likeValue) + } + if productCode := strings.TrimSpace(c.Query("productCode")); productCode != "" { + query = query.Where("product_code = ?", strings.ToLower(productCode)) + } + if projectTypeCode := strings.TrimSpace(c.Query("projectTypeCode")); projectTypeCode != "" { + query = query.Where("project_type_code = ?", strings.ToLower(projectTypeCode)) + } + if suiteCode := strings.TrimSpace(c.Query("suiteCode")); suiteCode != "" { + query = query.Where("suite_code = ?", suiteCode) + } + if status := strings.TrimSpace(c.Query("status")); status != "" { + query = query.Where("status = ?", strings.ToLower(status)) + } + if soldTargetType := strings.TrimSpace(c.Query("soldTargetType")); soldTargetType != "" { + query = query.Where("sold_target_type = ?", strings.ToLower(soldTargetType)) + } + if regionIDStr := strings.TrimSpace(c.Query("regionId")); regionIDStr != "" { + regionID, err := strconv.ParseUint(regionIDStr, 10, 32) + if err != nil || regionID == 0 { + writeError(c, http.StatusBadRequest, "invalid regionId") + return + } + query = query.Where("region_id = ?", uint32(regionID)) + } + + if err := query.Find(&items).Error; err != nil { + writeError(c, http.StatusInternalServerError, "failed to query product inventory") + return + } + writeSuccess(c, http.StatusOK, "query success", items) +} + +func (pc *ProductInventoryAdminController) Create(c *gin.Context) { + var payload productInventoryPayload + if err := c.ShouldBindJSON(&payload); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + if err := pc.validateInventoryPayload(payload); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + + record := models.ProductInventory{ + ProductCode: payload.ProductCode, + ProjectTypeCode: payload.ProjectTypeCode, + SuiteCode: payload.SuiteCode, + SerialNumber: payload.SerialNumber, + AssetName: payload.AssetName, + Status: models.ProductInventoryStatus(payload.Status), + RegionID: payload.RegionID, + StorageLocation: payload.StorageLocation, + SoldTargetType: payload.SoldTargetType, + SoldTargetName: payload.SoldTargetName, + SoldTo: payload.SoldTo, + SoldAt: payload.SoldAt, + ParameterValues: payload.ParameterValues, + Notes: payload.Notes, + } + pc.applySoldDefaults(&record) + + if err := pc.DB.Create(&record).Error; err != nil { + writeProductInventoryDBError(c, err) + return + } + writeSuccess(c, http.StatusCreated, "create success", record) +} + +func (pc *ProductInventoryAdminController) Update(c *gin.Context) { + record, err := pc.findByID(c.Param("id")) + if err != nil { + respondProductInventoryLookupError(c, err) + return + } + + var payload productInventoryPayload + if err := c.ShouldBindJSON(&payload); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + if err := pc.validateInventoryPayload(payload); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + + record.ProductCode = payload.ProductCode + record.ProjectTypeCode = payload.ProjectTypeCode + record.SuiteCode = payload.SuiteCode + record.SerialNumber = payload.SerialNumber + record.AssetName = payload.AssetName + record.Status = models.ProductInventoryStatus(payload.Status) + record.RegionID = payload.RegionID + record.StorageLocation = payload.StorageLocation + record.SoldTargetType = payload.SoldTargetType + record.SoldTargetName = payload.SoldTargetName + record.SoldTo = payload.SoldTo + record.SoldAt = payload.SoldAt + record.ParameterValues = payload.ParameterValues + record.Notes = payload.Notes + pc.applySoldDefaults(&record) + + if err := pc.DB.Save(&record).Error; err != nil { + writeProductInventoryDBError(c, err) + return + } + writeSuccess(c, http.StatusOK, "update success", record) +} + +func (pc *ProductInventoryAdminController) Delete(c *gin.Context) { + record, err := pc.findByID(c.Param("id")) + if err != nil { + respondProductInventoryLookupError(c, err) + return + } + if record.Status == models.ProductInventoryStatusSold { + writeError(c, http.StatusConflict, "sold inventory record cannot be deleted") + return + } + if err := pc.DB.Delete(&record).Error; err != nil { + writeError(c, http.StatusInternalServerError, "failed to delete product inventory") + return + } + writeSuccess(c, http.StatusOK, "delete success", nil) +} + +func (pc *ProductInventoryAdminController) findByID(id string) (models.ProductInventory, error) { + var record models.ProductInventory + numericID, err := strconv.ParseUint(strings.TrimSpace(id), 10, 64) + if err != nil { + return record, gorm.ErrRecordNotFound + } + if err := pc.DB.First(&record, numericID).Error; err != nil { + return record, err + } + return record, nil +} + +func (pc *ProductInventoryAdminController) validateInventoryPayload(payload productInventoryPayload) error { + productCode := strings.TrimSpace(strings.ToLower(payload.ProductCode)) + if productCode == "" { + return errors.New("productCode is required") + } + + var product models.ProductDefinition + if err := pc.DB.Where("code = ? AND is_active = ?", productCode, true).First(&product).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("productCode is invalid") + } + return errors.New("failed to query product definition") + } + + if strings.TrimSpace(payload.ProjectTypeCode) != "" { + var projectType models.ProjectType + if err := pc.DB.Where("code = ? AND is_active = ?", strings.ToLower(strings.TrimSpace(payload.ProjectTypeCode)), true).First(&projectType).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("projectTypeCode is invalid") + } + return errors.New("failed to query project type") + } + } + if strings.TrimSpace(payload.ParameterValues) != "" && !json.Valid([]byte(strings.TrimSpace(payload.ParameterValues))) { + return errors.New("parameterValues is invalid") + } + + switch models.ProductInventoryStatus(strings.ToLower(strings.TrimSpace(payload.Status))) { + case models.ProductInventoryStatusInStock, models.ProductInventoryStatusSold, models.ProductInventoryStatusMaintenance, models.ProductInventoryStatusRetired: + default: + return errors.New("status is invalid") + } + + return nil +} + +func (pc *ProductInventoryAdminController) applySoldDefaults(record *models.ProductInventory) { + if record == nil { + return + } + if record.Status == models.ProductInventoryStatusSold { + if record.SoldAt == nil { + now := time.Now() + record.SoldAt = &now + } + if strings.TrimSpace(record.SoldTo) == "" { + record.SoldTo = strings.TrimSpace(record.SoldTargetName) + } + return + } + record.SoldAt = nil + record.SoldTo = "" + record.SoldTargetType = "" + record.SoldTargetName = "" +} + +func respondProductInventoryLookupError(c *gin.Context, err error) { + if errors.Is(err, gorm.ErrRecordNotFound) { + writeError(c, http.StatusNotFound, "product inventory not found") + return + } + writeError(c, http.StatusInternalServerError, "failed to query product inventory") +} + +func writeProductInventoryDBError(c *gin.Context, err error) { + if strings.Contains(strings.ToLower(err.Error()), "unique") { + writeError(c, http.StatusConflict, "serial number already exists") + return + } + writeError(c, http.StatusInternalServerError, "failed to persist product inventory") +} diff --git a/controllers/project_product_template_admin.go b/controllers/project_product_template_admin.go new file mode 100644 index 0000000..f7a62de --- /dev/null +++ b/controllers/project_product_template_admin.go @@ -0,0 +1,103 @@ +package controllers + +import ( + "hr_receiver/config" + "hr_receiver/models" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type ProjectProductTemplateAdminController struct { + DB *gorm.DB +} + +type projectProductTemplateItem struct { + ID uint `json:"id"` + ProjectTypeCode string `json:"projectTypeCode"` + ProjectTypeName string `json:"projectTypeName"` + ProductCode string `json:"productCode"` + ProductName string `json:"productName"` + Quantity *int `json:"quantity"` + QuantityRule string `json:"quantityRule"` + IsOptional bool `json:"isOptional"` + Notes string `json:"notes"` + Sort int `json:"sort"` +} + +func NewProjectProductTemplateAdminController() *ProjectProductTemplateAdminController { + return &ProjectProductTemplateAdminController{DB: config.DB} +} + +func (pc *ProjectProductTemplateAdminController) List(c *gin.Context) { + var items []models.ProjectProductTemplate + query := pc.DB.Model(&models.ProjectProductTemplate{}).Order("project_type_code ASC, sort ASC, id ASC") + + if projectTypeCode := strings.TrimSpace(c.Query("projectTypeCode")); projectTypeCode != "" { + query = query.Where("project_type_code = ?", strings.ToLower(projectTypeCode)) + } + + if err := query.Find(&items).Error; err != nil { + writeError(c, http.StatusInternalServerError, "failed to query project product templates") + return + } + + projectTypeCodes := make([]string, 0) + projectTypeSet := make(map[string]struct{}) + productCodes := make([]string, 0) + productSet := make(map[string]struct{}) + for _, item := range items { + if _, ok := projectTypeSet[item.ProjectTypeCode]; !ok { + projectTypeSet[item.ProjectTypeCode] = struct{}{} + projectTypeCodes = append(projectTypeCodes, item.ProjectTypeCode) + } + if _, ok := productSet[item.ProductCode]; !ok { + productSet[item.ProductCode] = struct{}{} + productCodes = append(productCodes, item.ProductCode) + } + } + + projectTypeNameMap := make(map[string]string) + if len(projectTypeCodes) > 0 { + var projectTypes []models.ProjectType + if err := pc.DB.Where("code IN ?", projectTypeCodes).Find(&projectTypes).Error; err != nil { + writeError(c, http.StatusInternalServerError, "failed to query project types") + return + } + for _, item := range projectTypes { + projectTypeNameMap[item.Code] = item.Name + } + } + + productNameMap := make(map[string]string) + if len(productCodes) > 0 { + var productDefinitions []models.ProductDefinition + if err := pc.DB.Where("code IN ?", productCodes).Find(&productDefinitions).Error; err != nil { + writeError(c, http.StatusInternalServerError, "failed to query product definitions") + return + } + for _, item := range productDefinitions { + productNameMap[item.Code] = item.Name + } + } + + result := make([]projectProductTemplateItem, 0, len(items)) + for _, item := range items { + result = append(result, projectProductTemplateItem{ + ID: item.ID, + ProjectTypeCode: item.ProjectTypeCode, + ProjectTypeName: projectTypeNameMap[item.ProjectTypeCode], + ProductCode: item.ProductCode, + ProductName: productNameMap[item.ProductCode], + Quantity: item.Quantity, + QuantityRule: item.QuantityRule, + IsOptional: item.IsOptional, + Notes: item.Notes, + Sort: item.Sort, + }) + } + + writeSuccess(c, http.StatusOK, "query success", result) +} diff --git a/main.go b/main.go index 00aff19..7d2efee 100644 --- a/main.go +++ b/main.go @@ -31,6 +31,9 @@ func main() { &models.UserRegionBinding{}, &models.Kindergarten{}, &models.ProjectType{}, + &models.ProductDefinition{}, + &models.ProjectProductTemplate{}, + &models.ProductInventory{}, &models.AppFile{}, &models.AppFileShareCode{}, &models.MqttHeartRateRecord{}, @@ -53,6 +56,12 @@ func main() { if err := models.EnsureDefaultProjectTypes(config.DB); err != nil { log.Printf("default project types init failed: %v", err) } + if err := models.EnsureDefaultProductDefinitions(config.DB); err != nil { + log.Printf("default product definitions init failed: %v", err) + } + if err := models.EnsureDefaultProjectProductTemplates(config.DB); err != nil { + log.Printf("default project product templates init failed: %v", err) + } if err := mqtt.Start(config.DB, config.App.MQTT); err != nil { log.Printf("mqtt listener start failed: %v", err) diff --git a/models/device.go b/models/device.go index b2f9484..a61e7f7 100644 --- a/models/device.go +++ b/models/device.go @@ -7,7 +7,10 @@ import ( // Gateway 代表一个物联网网关设备 type Gateway struct { - gorm.Model + ID uint `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // MAC地址是网关的唯一标识 MAC string `gorm:"size:32;uniqueIndex;not null" json:"mac"` diff --git a/models/product_definition.go b/models/product_definition.go new file mode 100644 index 0000000..9f3116c --- /dev/null +++ b/models/product_definition.go @@ -0,0 +1,190 @@ +package models + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "gorm.io/gorm" +) + +type ProductDefinition struct { + ID uint `gorm:"primaryKey" json:"id"` + Code string `gorm:"size:64;not null;uniqueIndex" json:"code"` + Name string `gorm:"size:255;not null" json:"name"` + Category string `gorm:"size:64;not null;default:'device';index" json:"category"` + Description string `gorm:"size:1024" json:"description"` + ParameterSchema string `gorm:"type:text" json:"parameterSchema"` + TrackSerialNumber bool `gorm:"not null;default:false" json:"trackSerialNumber"` + IsActive bool `gorm:"not null;default:true;index" json:"isActive"` + Sort int `gorm:"not null;default:0;index" json:"sort"` + CreatedAt int64 `gorm:"not null" json:"created_at"` + UpdatedAt int64 `gorm:"not null" json:"updated_at"` +} + +func (ProductDefinition) TableName() string { + return "product_definitions" +} + +func (p *ProductDefinition) BeforeCreate(tx *gorm.DB) (err error) { + now := time.Now().UnixMilli() + p.Code = normalizeProductCodeValue(p.Code) + p.Name = strings.TrimSpace(p.Name) + p.Category = normalizeProductCategoryValue(p.Category) + p.Description = strings.TrimSpace(p.Description) + p.ParameterSchema = normalizeJSONTextValue(p.ParameterSchema) + p.CreatedAt = now + p.UpdatedAt = now + return nil +} + +func (p *ProductDefinition) BeforeUpdate(tx *gorm.DB) (err error) { + p.Code = normalizeProductCodeValue(p.Code) + p.Name = strings.TrimSpace(p.Name) + p.Category = normalizeProductCategoryValue(p.Category) + p.Description = strings.TrimSpace(p.Description) + p.ParameterSchema = normalizeJSONTextValue(p.ParameterSchema) + p.UpdatedAt = time.Now().UnixMilli() + return nil +} + +func normalizeProductCodeValue(code string) string { + return strings.TrimSpace(strings.ToLower(code)) +} + +func normalizeProductCategoryValue(category string) string { + value := strings.TrimSpace(strings.ToLower(category)) + if value == "" { + return "device" + } + return value +} + +func normalizeJSONTextValue(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + if !json.Valid([]byte(trimmed)) { + return "" + } + return trimmed +} + +type ProductParameterSchemaField struct { + Key string `json:"key"` + Label string `json:"label"` + Type string `json:"type"` + Required bool `json:"required"` + Options []string `json:"options,omitempty"` +} + +func mustJSON(value interface{}) string { + bytes, err := json.Marshal(value) + if err != nil { + return "[]" + } + return string(bytes) +} + +func EnsureDefaultProductDefinitions(db *gorm.DB) error { + defaults := []ProductDefinition{ + { + Code: "charging_case_large", + Name: "充电收纳箱(20位)", + Category: "container", + Description: "可收纳20个手环的大充电收纳箱", + ParameterSchema: mustJSON([]ProductParameterSchemaField{{Key: "capacity", Label: "容量", Type: "number", Required: false}, {Key: "powerAdapter", Label: "适配器型号", Type: "string", Required: false}}), + TrackSerialNumber: true, + IsActive: true, + Sort: 10, + }, + { + Code: "charging_case_small", + Name: "小充电收纳箱(10位)", + Category: "container", + Description: "可收纳10个手环的小充电收纳箱", + ParameterSchema: mustJSON([]ProductParameterSchemaField{{Key: "capacity", Label: "容量", Type: "number", Required: false}, {Key: "powerAdapter", Label: "适配器型号", Type: "string", Required: false}}), + TrackSerialNumber: true, + IsActive: true, + Sort: 20, + }, + { + Code: "collection_band", + Name: "采集手环", + Category: "wearable", + Description: "用于智能心率采集、50米往返跑等项目的采集手环", + ParameterSchema: mustJSON([]ProductParameterSchemaField{{Key: "bandSize", Label: "尺码", Type: "string", Required: false}, {Key: "firmwareVersion", Label: "固件版本", Type: "string", Required: false}}), + TrackSerialNumber: true, + IsActive: true, + Sort: 30, + }, + { + Code: "control_tablet", + Name: "控制平板", + Category: "tablet", + Description: "项目控制与操作使用的平板设备", + ParameterSchema: mustJSON([]ProductParameterSchemaField{{Key: "screenSize", Label: "屏幕尺寸", Type: "string", Required: false}, {Key: "osVersion", Label: "系统版本", Type: "string", Required: false}}), + TrackSerialNumber: true, + IsActive: true, + Sort: 40, + }, + { + Code: "collection_gateway", + Name: "采集网关", + Category: "gateway", + Description: "用于设备数据采集与上传的网关", + ParameterSchema: mustJSON([]ProductParameterSchemaField{{Key: "mac", Label: "MAC地址", Type: "string", Required: true}, {Key: "location", Label: "安装位置", Type: "string", Required: false}}), + TrackSerialNumber: true, + IsActive: true, + Sort: 50, + }, + { + Code: "display_stand_screen", + Name: "闺蜜机", + Category: "display", + Description: "用于投屏展示的移动显示设备", + ParameterSchema: mustJSON([]ProductParameterSchemaField{{Key: "screenSize", Label: "屏幕尺寸", Type: "string", Required: false}, {Key: "resolution", Label: "分辨率", Type: "string", Required: false}}), + TrackSerialNumber: true, + IsActive: true, + Sort: 60, + }, + { + Code: "led_light_strip", + Name: "LED灯带", + Category: "accessory", + Description: "心肺耐力测试配套的LED灯带", + ParameterSchema: mustJSON([]ProductParameterSchemaField{{Key: "length", Label: "长度", Type: "string", Required: false}, {Key: "colorMode", Label: "颜色模式", Type: "string", Required: false}}), + TrackSerialNumber: true, + IsActive: true, + Sort: 70, + }, + } + + for _, item := range defaults { + var existing ProductDefinition + err := db.Where("code = ?", item.Code).First(&existing).Error + switch { + case err == nil: + existing.Name = item.Name + existing.Category = item.Category + existing.Description = item.Description + existing.ParameterSchema = item.ParameterSchema + existing.TrackSerialNumber = item.TrackSerialNumber + existing.IsActive = item.IsActive + existing.Sort = item.Sort + if err := db.Save(&existing).Error; err != nil { + return err + } + case errors.Is(err, gorm.ErrRecordNotFound): + if err := db.Create(&item).Error; err != nil { + return err + } + default: + return err + } + } + + return nil +} diff --git a/models/product_inventory.go b/models/product_inventory.go new file mode 100644 index 0000000..c6e23b6 --- /dev/null +++ b/models/product_inventory.go @@ -0,0 +1,107 @@ +package models + +import ( + "strings" + "time" + + "gorm.io/gorm" +) + +type ProductInventoryStatus string + +const ( + ProductInventoryStatusInStock ProductInventoryStatus = "in_stock" + ProductInventoryStatusSold ProductInventoryStatus = "sold" + ProductInventoryStatusMaintenance ProductInventoryStatus = "maintenance" + ProductInventoryStatusRetired ProductInventoryStatus = "retired" +) + +type ProductInventory struct { + ID uint `gorm:"primaryKey" json:"id"` + ProductCode string `gorm:"size:64;not null;index" json:"productCode"` + ProjectTypeCode string `gorm:"size:64;index" json:"projectTypeCode"` + SuiteCode string `gorm:"size:128;index" json:"suiteCode"` + SerialNumber *string `gorm:"size:128;uniqueIndex" json:"serialNumber"` + AssetName string `gorm:"size:255" json:"assetName"` + Status ProductInventoryStatus `gorm:"size:32;not null;default:'in_stock';index" json:"status"` + RegionID *uint32 `gorm:"index" json:"regionId"` + StorageLocation string `gorm:"size:255" json:"storageLocation"` + SoldTargetType string `gorm:"size:64;index" json:"soldTargetType"` + SoldTargetName string `gorm:"size:255" json:"soldTargetName"` + SoldTo string `gorm:"size:255" json:"soldTo"` + SoldAt *time.Time `json:"soldAt"` + ParameterValues string `gorm:"type:text" json:"parameterValues"` + SourceType string `gorm:"size:64;index" json:"sourceType"` + SourceRef string `gorm:"size:128;index" json:"sourceRef"` + Notes string `gorm:"size:1024" json:"notes"` + CreatedAt int64 `gorm:"not null" json:"created_at"` + UpdatedAt int64 `gorm:"not null" json:"updated_at"` +} + +func (ProductInventory) TableName() string { + return "product_inventories" +} + +func (p *ProductInventory) BeforeCreate(tx *gorm.DB) (err error) { + now := time.Now().UnixMilli() + p.ProductCode = normalizeProductCodeValue(p.ProductCode) + p.ProjectTypeCode = strings.TrimSpace(strings.ToLower(p.ProjectTypeCode)) + p.SuiteCode = strings.TrimSpace(p.SuiteCode) + p.SerialNumber = normalizeOptionalProductString(p.SerialNumber) + p.AssetName = strings.TrimSpace(p.AssetName) + p.StorageLocation = strings.TrimSpace(p.StorageLocation) + p.SoldTargetType = strings.TrimSpace(strings.ToLower(p.SoldTargetType)) + p.SoldTargetName = strings.TrimSpace(p.SoldTargetName) + p.SoldTo = strings.TrimSpace(p.SoldTo) + p.ParameterValues = normalizeJSONTextValue(p.ParameterValues) + p.SourceType = strings.TrimSpace(strings.ToLower(p.SourceType)) + p.SourceRef = strings.TrimSpace(p.SourceRef) + p.Notes = strings.TrimSpace(p.Notes) + p.Status = normalizeProductInventoryStatus(p.Status) + p.CreatedAt = now + p.UpdatedAt = now + return nil +} + +func (p *ProductInventory) BeforeUpdate(tx *gorm.DB) (err error) { + p.ProductCode = normalizeProductCodeValue(p.ProductCode) + p.ProjectTypeCode = strings.TrimSpace(strings.ToLower(p.ProjectTypeCode)) + p.SuiteCode = strings.TrimSpace(p.SuiteCode) + p.SerialNumber = normalizeOptionalProductString(p.SerialNumber) + p.AssetName = strings.TrimSpace(p.AssetName) + p.StorageLocation = strings.TrimSpace(p.StorageLocation) + p.SoldTargetType = strings.TrimSpace(strings.ToLower(p.SoldTargetType)) + p.SoldTargetName = strings.TrimSpace(p.SoldTargetName) + p.SoldTo = strings.TrimSpace(p.SoldTo) + p.ParameterValues = normalizeJSONTextValue(p.ParameterValues) + p.SourceType = strings.TrimSpace(strings.ToLower(p.SourceType)) + p.SourceRef = strings.TrimSpace(p.SourceRef) + p.Notes = strings.TrimSpace(p.Notes) + p.Status = normalizeProductInventoryStatus(p.Status) + p.UpdatedAt = time.Now().UnixMilli() + return nil +} + +func normalizeOptionalProductString(value *string) *string { + if value == nil { + return nil + } + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func normalizeProductInventoryStatus(status ProductInventoryStatus) ProductInventoryStatus { + switch ProductInventoryStatus(strings.TrimSpace(strings.ToLower(string(status)))) { + case ProductInventoryStatusSold: + return ProductInventoryStatusSold + case ProductInventoryStatusMaintenance: + return ProductInventoryStatusMaintenance + case ProductInventoryStatusRetired: + return ProductInventoryStatusRetired + default: + return ProductInventoryStatusInStock + } +} diff --git a/models/project_product_template.go b/models/project_product_template.go new file mode 100644 index 0000000..3955677 --- /dev/null +++ b/models/project_product_template.go @@ -0,0 +1,104 @@ +package models + +import ( + "errors" + "strings" + "time" + + "gorm.io/gorm" +) + +type ProjectProductTemplate struct { + ID uint `gorm:"primaryKey" json:"id"` + ProjectTypeCode string `gorm:"size:64;not null;index;uniqueIndex:idx_project_product_template" json:"projectTypeCode"` + ProductCode string `gorm:"size:64;not null;index;uniqueIndex:idx_project_product_template" json:"productCode"` + Quantity *int `json:"quantity"` + QuantityRule string `gorm:"size:32;not null;default:'exact'" json:"quantityRule"` + IsOptional bool `gorm:"not null;default:false" json:"isOptional"` + Notes string `gorm:"size:1024" json:"notes"` + Sort int `gorm:"not null;default:0;index" json:"sort"` + CreatedAt int64 `gorm:"not null" json:"created_at"` + UpdatedAt int64 `gorm:"not null" json:"updated_at"` +} + +func (ProjectProductTemplate) TableName() string { + return "project_product_templates" +} + +func (p *ProjectProductTemplate) BeforeCreate(tx *gorm.DB) (err error) { + now := time.Now().UnixMilli() + p.ProjectTypeCode = strings.TrimSpace(strings.ToLower(p.ProjectTypeCode)) + p.ProductCode = normalizeProductCodeValue(p.ProductCode) + p.QuantityRule = normalizeQuantityRuleValue(p.QuantityRule) + p.Notes = strings.TrimSpace(p.Notes) + p.CreatedAt = now + p.UpdatedAt = now + return nil +} + +func (p *ProjectProductTemplate) BeforeUpdate(tx *gorm.DB) (err error) { + p.ProjectTypeCode = strings.TrimSpace(strings.ToLower(p.ProjectTypeCode)) + p.ProductCode = normalizeProductCodeValue(p.ProductCode) + p.QuantityRule = normalizeQuantityRuleValue(p.QuantityRule) + p.Notes = strings.TrimSpace(p.Notes) + p.UpdatedAt = time.Now().UnixMilli() + return nil +} + +func normalizeQuantityRuleValue(rule string) string { + value := strings.TrimSpace(strings.ToLower(rule)) + if value == "" { + return "exact" + } + return value +} + +func intPtr(value int) *int { + return &value +} + +func EnsureDefaultProjectProductTemplates(db *gorm.DB) error { + defaults := []ProjectProductTemplate{ + {ProjectTypeCode: "heartrate", ProductCode: "charging_case_large", Quantity: intPtr(1), QuantityRule: "exact", IsOptional: false, Notes: "可收纳20个手环", Sort: 10}, + {ProjectTypeCode: "heartrate", ProductCode: "charging_case_small", Quantity: intPtr(1), QuantityRule: "exact", IsOptional: true, Notes: "可选,可收纳10个手环", Sort: 20}, + {ProjectTypeCode: "heartrate", ProductCode: "collection_band", Quantity: nil, QuantityRule: "flexible", IsOptional: false, Notes: "数量按班级规模或实际采购数量配置", Sort: 30}, + {ProjectTypeCode: "heartrate", ProductCode: "control_tablet", Quantity: intPtr(1), QuantityRule: "exact", IsOptional: false, Notes: "", Sort: 40}, + {ProjectTypeCode: "heartrate", ProductCode: "collection_gateway", Quantity: intPtr(1), QuantityRule: "minimum", IsOptional: false, Notes: "至少1个", Sort: 50}, + {ProjectTypeCode: "heartrate", ProductCode: "display_stand_screen", Quantity: intPtr(1), QuantityRule: "exact", IsOptional: true, Notes: "可选,用于投屏展示", Sort: 60}, + + {ProjectTypeCode: "light", ProductCode: "charging_case_large", Quantity: intPtr(1), QuantityRule: "exact", IsOptional: false, Notes: "可收纳20个手环", Sort: 110}, + {ProjectTypeCode: "light", ProductCode: "charging_case_small", Quantity: intPtr(1), QuantityRule: "exact", IsOptional: true, Notes: "可选,可收纳10个手环", Sort: 120}, + {ProjectTypeCode: "light", ProductCode: "collection_band", Quantity: nil, QuantityRule: "flexible", IsOptional: false, Notes: "数量按班级规模或实际采购数量配置", Sort: 130}, + {ProjectTypeCode: "light", ProductCode: "control_tablet", Quantity: intPtr(1), QuantityRule: "exact", IsOptional: false, Notes: "", Sort: 140}, + {ProjectTypeCode: "light", ProductCode: "collection_gateway", Quantity: intPtr(1), QuantityRule: "minimum", IsOptional: false, Notes: "至少1个", Sort: 150}, + {ProjectTypeCode: "light", ProductCode: "display_stand_screen", Quantity: intPtr(1), QuantityRule: "exact", IsOptional: true, Notes: "可选,用于投屏展示", Sort: 160}, + {ProjectTypeCode: "light", ProductCode: "led_light_strip", Quantity: intPtr(1), QuantityRule: "exact", IsOptional: false, Notes: "心肺耐力测试附加LED灯带", Sort: 170}, + + {ProjectTypeCode: "run50", ProductCode: "control_tablet", Quantity: intPtr(1), QuantityRule: "exact", IsOptional: false, Notes: "", Sort: 210}, + {ProjectTypeCode: "run50", ProductCode: "collection_band", Quantity: nil, QuantityRule: "flexible", IsOptional: false, Notes: "数量按测试规模配置", Sort: 220}, + } + + for _, item := range defaults { + var existing ProjectProductTemplate + err := db.Where("project_type_code = ? AND product_code = ?", item.ProjectTypeCode, item.ProductCode).First(&existing).Error + switch { + case err == nil: + existing.Quantity = item.Quantity + existing.QuantityRule = item.QuantityRule + existing.IsOptional = item.IsOptional + existing.Notes = item.Notes + existing.Sort = item.Sort + if err := db.Save(&existing).Error; err != nil { + return err + } + case errors.Is(err, gorm.ErrRecordNotFound): + if err := db.Create(&item).Error; err != nil { + return err + } + default: + return err + } + } + + return nil +} diff --git a/routes/routes.go b/routes/routes.go index 9b6ecea..8b6eee5 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -16,6 +16,9 @@ func SetupRouter() *gin.Engine { lessonPlanController := controllers.NewLessonPlanController() kindergartenAdminController := controllers.NewKindergartenAdminController() projectTypeAdminController := controllers.NewProjectTypeAdminController() + productDefinitionAdminController := controllers.NewProductDefinitionAdminController() + projectProductTemplateAdminController := controllers.NewProjectProductTemplateAdminController() + productInventoryAdminController := controllers.NewProductInventoryAdminController() userAdminController := controllers.NewUserAdminController() gatewayController := controllers.NewGatewayAdminController() systemDebugController := controllers.NewSystemDebugController() @@ -79,6 +82,18 @@ func SetupRouter() *gin.Engine { admin.PUT("/project-types/:id", projectTypeAdminController.Update) admin.DELETE("/project-types/:id", projectTypeAdminController.Delete) + admin.GET("/product-definitions", productDefinitionAdminController.List) + admin.POST("/product-definitions", productDefinitionAdminController.Create) + admin.PUT("/product-definitions/:id", productDefinitionAdminController.Update) + admin.DELETE("/product-definitions/:id", productDefinitionAdminController.Delete) + + admin.GET("/project-product-templates", projectProductTemplateAdminController.List) + + admin.GET("/product-inventories", productInventoryAdminController.List) + admin.POST("/product-inventories", productInventoryAdminController.Create) + admin.PUT("/product-inventories/:id", productInventoryAdminController.Update) + admin.DELETE("/product-inventories/:id", productInventoryAdminController.Delete) + admin.GET("/users", userAdminController.List) admin.POST("/users", userAdminController.Create) admin.PUT("/users/:id", userAdminController.Update)