From c697e668e332a4c4ee7b0b5005268ef7131172e4 Mon Sep 17 00:00:00 2001 From: huang <1724659546@qq.com> Date: Thu, 20 Nov 2025 13:43:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=8E=9F=E6=9D=90=E6=96=99?= =?UTF-8?q?=E7=9A=84=E5=A2=9E=E5=88=A0=E6=94=B9=E6=9F=A5=E5=92=8C=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E5=B1=82=E7=9A=84=E5=8E=9F=E6=96=99=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E8=A1=A8=E5=A2=9E=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design/archive/recipe-management/index.md | 3 +- internal/domain/recipe/recipe_service.go | 154 ++++++++++++++++-- internal/infra/models/raw_material.go | 15 +- .../infra/repository/nutrient_repository.go | 20 +-- .../repository/raw_material_repository.go | 140 ++++++++++++++++ 5 files changed, 296 insertions(+), 36 deletions(-) diff --git a/design/archive/recipe-management/index.md b/design/archive/recipe-management/index.md index e721802..9cc4f67 100644 --- a/design/archive/recipe-management/index.md +++ b/design/archive/recipe-management/index.md @@ -50,4 +50,5 @@ http://git.huangwc.com/pig/pig-farm-controller/issues/66 1. 定义原料表, 营养表, 原料营养表, 原料库存变更表 2. 迁移配置文件, 实现从json文件中读取原材料营养预设值, 并自动写入数据库 -3. 定义配方领域, 实现营养元素的增删改查 \ No newline at end of file +3. 定义配方领域, 实现营养元素的增删改查 +4. 实现原材料的增删改查和仓库层的原料库存记录表增查 \ No newline at end of file diff --git a/internal/domain/recipe/recipe_service.go b/internal/domain/recipe/recipe_service.go index 5eaad96..f6b22e2 100644 --- a/internal/domain/recipe/recipe_service.go +++ b/internal/domain/recipe/recipe_service.go @@ -2,11 +2,14 @@ package recipe import ( "context" + "errors" "fmt" "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" "git.huangwc.com/pig/pig-farm-controller/internal/infra/models" "git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" + + "gorm.io/gorm" ) // 定义领域特定的错误 @@ -14,28 +17,41 @@ var ( ErrNutrientNameConflict = fmt.Errorf("营养种类名称已存在") ErrNutrientNotFound = fmt.Errorf("营养种类不存在") ErrNutrientInUse = fmt.Errorf("营养种类正在被原料使用,无法删除") + + ErrRawMaterialNameConflict = fmt.Errorf("原料名称已存在") + ErrRawMaterialNotFound = fmt.Errorf("原料不存在") ) // Service 定义了配方与原料领域的核心业务服务接口 type Service interface { + // 营养种类相关接口 CreateNutrient(ctx context.Context, name, description string) (*models.Nutrient, error) UpdateNutrient(ctx context.Context, id uint32, name, description string) (*models.Nutrient, error) DeleteNutrient(ctx context.Context, id uint32) error GetNutrient(ctx context.Context, id uint32) (*models.Nutrient, error) ListNutrients(ctx context.Context, page, pageSize int) ([]models.Nutrient, int64, error) + + // 原料相关接口 + CreateRawMaterial(ctx context.Context, name, description string) (*models.RawMaterial, error) + UpdateRawMaterial(ctx context.Context, id uint32, name, description string) (*models.RawMaterial, error) + DeleteRawMaterial(ctx context.Context, id uint32) error + GetRawMaterial(ctx context.Context, id uint32) (*models.RawMaterial, error) + ListRawMaterials(ctx context.Context, page, pageSize int) ([]models.RawMaterial, int64, error) } // recipeServiceImpl 是 RecipeService 的实现 type recipeServiceImpl struct { - ctx context.Context - nutrientRepo repository.NutrientRepository + ctx context.Context + nutrientRepo repository.NutrientRepository + rawMaterialRepo repository.RawMaterialRepository } // NewRecipeService 创建一个新的 RecipeService 实例 -func NewRecipeService(ctx context.Context, nutrientRepo repository.NutrientRepository) Service { +func NewRecipeService(ctx context.Context, nutrientRepo repository.NutrientRepository, rawMaterialRepo repository.RawMaterialRepository) Service { return &recipeServiceImpl{ - ctx: ctx, - nutrientRepo: nutrientRepo, + ctx: ctx, + nutrientRepo: nutrientRepo, + rawMaterialRepo: rawMaterialRepo, } } @@ -45,7 +61,7 @@ func (s *recipeServiceImpl) CreateNutrient(ctx context.Context, name, descriptio // 检查名称是否已存在 existing, err := s.nutrientRepo.GetNutrientByName(serviceCtx, name) - if err != nil { + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { // 只有不是记录未找到的错误才返回 return nil, fmt.Errorf("检查营养种类名称失败: %w", err) } if existing != nil { @@ -71,16 +87,16 @@ func (s *recipeServiceImpl) UpdateNutrient(ctx context.Context, id uint32, name, // 检查要更新的实体是否存在 nutrient, err := s.nutrientRepo.GetNutrientByID(serviceCtx, id) if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { // 如果是记录未找到错误,则返回领域错误 + return nil, ErrNutrientNotFound + } return nil, fmt.Errorf("获取待更新的营养种类失败: %w", err) } - if nutrient == nil { - return nil, ErrNutrientNotFound - } // 如果名称有变动,检查新名称是否与其它记录冲突 if nutrient.Name != name { existing, err := s.nutrientRepo.GetNutrientByName(serviceCtx, name) - if err != nil { + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, fmt.Errorf("检查新的营养种类名称失败: %w", err) } if existing != nil && existing.ID != id { @@ -103,13 +119,13 @@ func (s *recipeServiceImpl) DeleteNutrient(ctx context.Context, id uint32) error serviceCtx := logs.AddFuncName(ctx, s.ctx, "DeleteNutrient") // 检查实体是否存在 - nutrient, err := s.nutrientRepo.GetNutrientByID(serviceCtx, id) + _, err := s.nutrientRepo.GetNutrientByID(serviceCtx, id) if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNutrientNotFound + } return fmt.Errorf("获取待删除的营养种类失败: %w", err) } - if nutrient == nil { - return ErrNutrientNotFound - } if err := s.nutrientRepo.DeleteNutrient(serviceCtx, id); err != nil { return fmt.Errorf("删除营养种类失败: %w", err) @@ -124,11 +140,11 @@ func (s *recipeServiceImpl) GetNutrient(ctx context.Context, id uint32) (*models nutrient, err := s.nutrientRepo.GetNutrientByID(serviceCtx, id) if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNutrientNotFound + } return nil, fmt.Errorf("获取营养种类失败: %w", err) } - if nutrient == nil { - return nil, ErrNutrientNotFound - } return nutrient, nil } @@ -142,3 +158,107 @@ func (s *recipeServiceImpl) ListNutrients(ctx context.Context, page, pageSize in } return nutrients, total, nil } + +// CreateRawMaterial 实现了创建原料的核心业务逻辑 +func (s *recipeServiceImpl) CreateRawMaterial(ctx context.Context, name, description string) (*models.RawMaterial, error) { + serviceCtx := logs.AddFuncName(ctx, s.ctx, "CreateRawMaterial") + + // 检查名称是否已存在 + existing, err := s.rawMaterialRepo.GetRawMaterialByName(serviceCtx, name) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("检查原料名称失败: %w", err) + } + if existing != nil { + return nil, ErrRawMaterialNameConflict + } + + rawMaterial := &models.RawMaterial{ + Name: name, + Description: description, + } + + if err := s.rawMaterialRepo.CreateRawMaterial(serviceCtx, rawMaterial); err != nil { + return nil, fmt.Errorf("创建原料失败: %w", err) + } + + return rawMaterial, nil +} + +// UpdateRawMaterial 实现了更新原料的核心业务逻辑 +func (s *recipeServiceImpl) UpdateRawMaterial(ctx context.Context, id uint32, name, description string) (*models.RawMaterial, error) { + serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdateRawMaterial") + + // 检查要更新的实体是否存在 + rawMaterial, err := s.rawMaterialRepo.GetRawMaterialByID(serviceCtx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrRawMaterialNotFound + } + return nil, fmt.Errorf("获取待更新的原料失败: %w", err) + } + + // 如果名称有变动,检查新名称是否与其它记录冲突 + if rawMaterial.Name != name { + existing, err := s.rawMaterialRepo.GetRawMaterialByName(serviceCtx, name) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("检查新的原料名称失败: %w", err) + } + if existing != nil && existing.ID != id { + return nil, ErrRawMaterialNameConflict + } + } + + rawMaterial.Name = name + rawMaterial.Description = description + + if err := s.rawMaterialRepo.UpdateRawMaterial(serviceCtx, rawMaterial); err != nil { + return nil, fmt.Errorf("更新原料失败: %w", err) + } + + return rawMaterial, nil +} + +// DeleteRawMaterial 实现了删除原料的核心业务逻辑 +func (s *recipeServiceImpl) DeleteRawMaterial(ctx context.Context, id uint32) error { + serviceCtx := logs.AddFuncName(ctx, s.ctx, "DeleteRawMaterial") + + // 检查实体是否存在 + _, err := s.rawMaterialRepo.GetRawMaterialByID(serviceCtx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrRawMaterialNotFound + } + return fmt.Errorf("获取待删除的原料失败: %w", err) + } + + if err := s.rawMaterialRepo.DeleteRawMaterial(serviceCtx, id); err != nil { + return fmt.Errorf("删除原料失败: %w", err) + } + + return nil +} + +// GetRawMaterial 实现了获取单个原料的逻辑 +func (s *recipeServiceImpl) GetRawMaterial(ctx context.Context, id uint32) (*models.RawMaterial, error) { + serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetRawMaterial") + + rawMaterial, err := s.rawMaterialRepo.GetRawMaterialByID(serviceCtx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrRawMaterialNotFound + } + return nil, fmt.Errorf("获取原料失败: %w", err) + } + return rawMaterial, nil +} + +// ListRawMaterials 实现了列出原料的逻辑 +func (s *recipeServiceImpl) ListRawMaterials(ctx context.Context, page, pageSize int) ([]models.RawMaterial, int64, error) { + serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListRawMaterials") + + rawMaterials, total, err := s.rawMaterialRepo.ListRawMaterials(serviceCtx, page, pageSize) + if err != nil { + return nil, 0, fmt.Errorf("获取原料列表失败: %w", err) + } + return rawMaterials, total, nil +} diff --git a/internal/infra/models/raw_material.go b/internal/infra/models/raw_material.go index b992cb4..703f6b2 100644 --- a/internal/infra/models/raw_material.go +++ b/internal/infra/models/raw_material.go @@ -23,9 +23,8 @@ type RawMaterial struct { Model Name string `gorm:"size:100;unique;not null;comment:原料名称"` Description string `gorm:"size:255;comment:描述"` - // Quantity 是当前库存的快照值,用于提供高性能的库存查询。 - // 注意:此字段的值必须在数据库事务中与 RawMaterialStockLog 同步更新,以保证数据一致性。 - Quantity float32 `gorm:"not null;default:0;comment:当前库存快照, 单位: g"` + // RawMaterialNutrients 关联此原料的所有营养素含量信息 + RawMaterialNutrients []RawMaterialNutrient `gorm:"foreignKey:RawMaterialID"` } func (RawMaterial) TableName() string { @@ -39,6 +38,8 @@ type Nutrient struct { Model Name string `gorm:"size:100;unique;not null;comment:营养素名称"` Description string `gorm:"size:255;comment:描述"` + // RawMaterialNutrients 记录营养在哪些原料中存在且比例是多少 + RawMaterialNutrients []RawMaterialNutrient `gorm:"foreignKey:NutrientID"` } func (Nutrient) TableName() string { @@ -66,9 +67,11 @@ func (RawMaterialNutrient) TableName() string { // 它是保证数据一致性和可审计性的核心。 type RawMaterialStockLog struct { Model - RawMaterialID uint32 `gorm:"not null;index;comment:关联的原料ID"` - RawMaterial RawMaterial `gorm:"foreignKey:RawMaterialID"` - ChangeAmount float32 `gorm:"not null;comment:变动数量, 正数为入库, 负数为出库, 单位: g"` + RawMaterialID uint32 `gorm:"not null;index;comment:关联的原料ID"` + RawMaterial RawMaterial `gorm:"foreignKey:RawMaterialID"` + ChangeAmount float32 `gorm:"not null;comment:变动数量, 正数为入库, 负数为出库, 单位: g"` + BeforeQuantity float32 `gorm:"not null;comment:变动前库存数量, 单位: g"` + AfterQuantity float32 `gorm:"not null;comment:变动后库存数量, 单位: g"` // SourceType 告知 SourceID 关联的是哪种类型的业务单据。 SourceType StockLogSourceType `gorm:"size:50;not null;index;comment:库存变动来源类型"` // SourceID 是一个多态外键,关联到触发此次变动的业务单据ID (如采购单ID)。 diff --git a/internal/infra/repository/nutrient_repository.go b/internal/infra/repository/nutrient_repository.go index 70b7ef1..f2e89c3 100644 --- a/internal/infra/repository/nutrient_repository.go +++ b/internal/infra/repository/nutrient_repository.go @@ -38,33 +38,29 @@ func (r *gormNutrientRepository) CreateNutrient(ctx context.Context, nutrient *m return r.db.WithContext(repoCtx).Create(nutrient).Error } -// GetNutrientByID 根据ID获取单个营养种类 +// GetNutrientByID 根据ID获取单个营养种类,并预加载关联的原料信息 func (r *gormNutrientRepository) GetNutrientByID(ctx context.Context, id uint32) (*models.Nutrient, error) { repoCtx := logs.AddFuncName(ctx, r.ctx, "GetNutrientByID") var nutrient models.Nutrient - if err := r.db.WithContext(repoCtx).First(&nutrient, id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil // 记录未找到不应视为错误 - } + // 如果记录未找到,GORM 会返回 gorm.ErrRecordNotFound 错误 + if err := r.db.WithContext(repoCtx).Preload("RawMaterialNutrients.RawMaterial").First(&nutrient, id).Error; err != nil { return nil, err } return &nutrient, nil } -// GetNutrientByName 根据名称获取单个营养种类 +// GetNutrientByName 根据名称获取单个营养种类,并预加载关联的原料信息 func (r *gormNutrientRepository) GetNutrientByName(ctx context.Context, name string) (*models.Nutrient, error) { repoCtx := logs.AddFuncName(ctx, r.ctx, "GetNutrientByName") var nutrient models.Nutrient - if err := r.db.WithContext(repoCtx).Where("name = ?", name).First(&nutrient).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil // 记录未找到不应视为错误 - } + // 如果记录未找到,GORM 会返回 gorm.ErrRecordNotFound 错误 + if err := r.db.WithContext(repoCtx).Preload("RawMaterialNutrients.RawMaterial").Where("name = ?", name).First(&nutrient).Error; err != nil { return nil, err } return &nutrient, nil } -// ListNutrients 列出所有营养种类(分页) +// ListNutrients 列出所有营养种类(分页),并预加载关联的原料信息 func (r *gormNutrientRepository) ListNutrients(ctx context.Context, page, pageSize int) ([]models.Nutrient, int64, error) { repoCtx := logs.AddFuncName(ctx, r.ctx, "ListNutrients") var nutrients []models.Nutrient @@ -79,7 +75,7 @@ func (r *gormNutrientRepository) ListNutrients(ctx context.Context, page, pageSi // 然后应用分页并获取数据 offset := (page - 1) * pageSize - if err := db.Offset(offset).Limit(pageSize).Find(&nutrients).Error; err != nil { + if err := db.Preload("RawMaterialNutrients.RawMaterial").Offset(offset).Limit(pageSize).Find(&nutrients).Error; err != nil { return nil, 0, err } diff --git a/internal/infra/repository/raw_material_repository.go b/internal/infra/repository/raw_material_repository.go index da7d37c..3f40c43 100644 --- a/internal/infra/repository/raw_material_repository.go +++ b/internal/infra/repository/raw_material_repository.go @@ -2,12 +2,27 @@ package repository import ( "context" + "errors" + "fmt" + + "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" + "git.huangwc.com/pig/pig-farm-controller/internal/infra/models" "gorm.io/gorm" ) // RawMaterialRepository 定义了与原料相关的数据库操作接口 type RawMaterialRepository interface { + CreateRawMaterial(ctx context.Context, rawMaterial *models.RawMaterial) error + GetRawMaterialByID(ctx context.Context, id uint32) (*models.RawMaterial, error) + GetRawMaterialByName(ctx context.Context, name string) (*models.RawMaterial, error) + ListRawMaterials(ctx context.Context, page, pageSize int) ([]models.RawMaterial, int64, error) + UpdateRawMaterial(ctx context.Context, rawMaterial *models.RawMaterial) error + DeleteRawMaterial(ctx context.Context, id uint32) error + + // 库存日志相关方法 + CreateRawMaterialStockLog(ctx context.Context, log *models.RawMaterialStockLog) error + GetLatestRawMaterialStockLog(ctx context.Context, rawMaterialID uint32) (*models.RawMaterialStockLog, error) } // gormRawMaterialRepository 是 RawMaterialRepository 的 GORM 实现 @@ -20,3 +35,128 @@ type gormRawMaterialRepository struct { func NewGormRawMaterialRepository(ctx context.Context, db *gorm.DB) RawMaterialRepository { return &gormRawMaterialRepository{ctx: ctx, db: db} } + +// CreateRawMaterial 创建一个新的原料 +func (r *gormRawMaterialRepository) CreateRawMaterial(ctx context.Context, rawMaterial *models.RawMaterial) error { + repoCtx := logs.AddFuncName(ctx, r.ctx, "CreateRawMaterial") + return r.db.WithContext(repoCtx).Create(rawMaterial).Error +} + +// GetRawMaterialByID 根据ID获取单个原料,并预加载关联的营养素信息 +func (r *gormRawMaterialRepository) GetRawMaterialByID(ctx context.Context, id uint32) (*models.RawMaterial, error) { + repoCtx := logs.AddFuncName(ctx, r.ctx, "GetRawMaterialByID") + var rawMaterial models.RawMaterial + // 如果记录未找到,GORM 会返回 gorm.ErrRecordNotFound 错误 + if err := r.db.WithContext(repoCtx).Preload("RawMaterialNutrients.Nutrient").First(&rawMaterial, id).Error; err != nil { + return nil, err + } + return &rawMaterial, nil +} + +// GetRawMaterialByName 根据名称获取单个原料,并预加载关联的营养素信息 +func (r *gormRawMaterialRepository) GetRawMaterialByName(ctx context.Context, name string) (*models.RawMaterial, error) { + repoCtx := logs.AddFuncName(ctx, r.ctx, "GetRawMaterialByName") + var rawMaterial models.RawMaterial + // 如果记录未找到,GORM 会返回 gorm.ErrRecordNotFound 错误 + if err := r.db.WithContext(repoCtx).Preload("RawMaterialNutrients.Nutrient").Where("name = ?", name).First(&rawMaterial).Error; err != nil { + return nil, err + } + return &rawMaterial, nil +} + +// ListRawMaterials 列出所有原料(分页),并预加载关联的营养素信息 +func (r *gormRawMaterialRepository) ListRawMaterials(ctx context.Context, page, pageSize int) ([]models.RawMaterial, int64, error) { + repoCtx := logs.AddFuncName(ctx, r.ctx, "ListRawMaterials") + var rawMaterials []models.RawMaterial + var total int64 + + db := r.db.WithContext(repoCtx).Model(&models.RawMaterial{}) + + // 首先计算总数 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + // 然后应用分页并获取数据 + offset := (page - 1) * pageSize + if err := db.Preload("RawMaterialNutrients.Nutrient").Offset(offset).Limit(pageSize).Find(&rawMaterials).Error; err != nil { + return nil, 0, err + } + + return rawMaterials, total, nil +} + +// UpdateRawMaterial 更新一个原料 +func (r *gormRawMaterialRepository) UpdateRawMaterial(ctx context.Context, rawMaterial *models.RawMaterial) error { + repoCtx := logs.AddFuncName(ctx, r.ctx, "UpdateRawMaterial") + // 使用 map 更新以避免 GORM 的零值问题,并确保只更新指定字段 + updateData := map[string]interface{}{ + "name": rawMaterial.Name, + "description": rawMaterial.Description, + } + result := r.db.WithContext(repoCtx).Model(&models.RawMaterial{}).Where("id = ?", rawMaterial.ID).Updates(updateData) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("未找到要更新的原料,ID: %d", rawMaterial.ID) + } + return nil +} + +// DeleteRawMaterial 根据ID删除一个原料,并级联软删除关联的 RawMaterialNutrient 和 RawMaterialStockLog 记录 +func (r *gormRawMaterialRepository) DeleteRawMaterial(ctx context.Context, id uint32) error { + repoCtx := logs.AddFuncName(ctx, r.ctx, "DeleteRawMaterial") + + return r.db.WithContext(repoCtx).Transaction(func(tx *gorm.DB) error { + // 1. 查找 RawMaterial 记录,确保其存在 + var rawMaterial models.RawMaterial + if err := tx.First(&rawMaterial, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("未找到要删除的原料,ID: %d", id) + } + return fmt.Errorf("查询原料失败: %w", err) + } + + // 2. 软删除所有关联的 RawMaterialNutrient 记录 + if err := tx.Where("raw_material_id = ?", id).Delete(&models.RawMaterialNutrient{}).Error; err != nil { + return fmt.Errorf("软删除关联的原料营养素记录失败: %w", err) + } + + // 3. 软删除所有关联的 RawMaterialStockLog 记录 + if err := tx.Where("raw_material_id = ?", id).Delete(&models.RawMaterialStockLog{}).Error; err != nil { + return fmt.Errorf("软删除关联的原料库存日志记录失败: %w", err) + } + + // 4. 软删除 RawMaterial 记录本身 + if err := tx.Delete(&rawMaterial).Error; err != nil { + return fmt.Errorf("软删除原料失败: %w", err) + } + + return nil + }) +} + +// CreateRawMaterialStockLog 创建一条新的原料库存日志 +func (r *gormRawMaterialRepository) CreateRawMaterialStockLog(ctx context.Context, log *models.RawMaterialStockLog) error { + repoCtx := logs.AddFuncName(ctx, r.ctx, "CreateRawMaterialStockLog") + return r.db.WithContext(repoCtx).Create(log).Error +} + +// GetLatestRawMaterialStockLog 获取指定原料的最新一条库存日志 +func (r *gormRawMaterialRepository) GetLatestRawMaterialStockLog(ctx context.Context, rawMaterialID uint32) (*models.RawMaterialStockLog, error) { + repoCtx := logs.AddFuncName(ctx, r.ctx, "GetLatestRawMaterialStockLog") + var latestLog models.RawMaterialStockLog + err := r.db.WithContext(repoCtx). + Where("raw_material_id = ?", rawMaterialID). + Order("happened_at DESC, id DESC"). // 优先按时间降序,然后按ID降序确保唯一最新 + First(&latestLog).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil // 如果没有日志记录,不视为错误,返回nil + } + return nil, err + } + return &latestLog, nil +}