实现库存管理相关逻辑

This commit is contained in:
2025-11-25 18:10:28 +08:00
parent ae27eb142d
commit 44ff3b19d6
15 changed files with 1531 additions and 22 deletions

View File

@@ -23,6 +23,7 @@ import (
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/device"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/feed"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/health"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/inventory"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/management"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/monitor"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/plan"
@@ -62,6 +63,7 @@ type API struct {
pigTypeController *feed.PigTypeController // 猪种类控制器实例
rawMaterialController *feed.RawMaterialController // 原料控制器实例
recipeController *feed.RecipeController // 配方控制器实例
inventoryController *inventory.InventoryController // 库存控制器实例
listenHandler webhook.ListenHandler // 设备上行事件监听器
analysisTaskManager *domain_plan.AnalysisPlanTaskManager // 计划触发器管理器实例
}
@@ -85,6 +87,7 @@ func NewAPI(cfg config.ServerConfig,
pigAgeStageService service.PigAgeStageService,
pigTypeService service.PigTypeService,
recipeService service.RecipeService,
inventoryService service.InventoryService,
tokenGenerator token.Generator,
listenHandler webhook.ListenHandler,
) *API {
@@ -122,6 +125,7 @@ func NewAPI(cfg config.ServerConfig,
pigTypeController: feed.NewPigTypeController(logs.AddCompName(baseCtx, "PigTypeController"), pigTypeService),
rawMaterialController: feed.NewRawMaterialController(logs.AddCompName(baseCtx, "RawMaterialController"), rawMaterialService),
recipeController: feed.NewRecipeController(logs.AddCompName(baseCtx, "RecipeController"), recipeService),
inventoryController: inventory.NewInventoryController(logs.AddCompName(baseCtx, "InventoryController"), inventoryService),
}
api.setupRoutes() // 设置所有路由

View File

@@ -261,6 +261,15 @@ func (a *API) setupRoutes() {
feedGroup.GET("/recipes", a.recipeController.ListRecipes)
}
logger.Debug("饲料管理相关接口注册成功 (需要认证和审计)")
// 库存管理相关路由组
inventoryGroup := authGroup.Group("/inventory")
{
inventoryGroup.POST("/stock/adjust", a.inventoryController.AdjustStock)
inventoryGroup.GET("/stock/current", a.inventoryController.ListCurrentStock)
inventoryGroup.GET("/stock/logs", a.inventoryController.ListStockLogs)
}
logger.Debug("库存管理相关接口注册成功 (需要认证和审计)")
}
logger.Debug("所有接口注册成功")

View File

@@ -0,0 +1,121 @@
package inventory
import (
"context"
"errors"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"git.huangwc.com/pig/pig-farm-controller/internal/app/service"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"github.com/labstack/echo/v4"
)
// InventoryController 定义了库存相关的控制器
type InventoryController struct {
ctx context.Context
inventoryService service.InventoryService
}
// NewInventoryController 创建一个新的 InventoryController 实例
func NewInventoryController(ctx context.Context, inventoryService service.InventoryService) *InventoryController {
return &InventoryController{
ctx: ctx,
inventoryService: inventoryService,
}
}
// AdjustStock godoc
// @Summary 调整原料库存
// @Description 手动调整指定原料的库存量。
// @Tags 库存管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param request body dto.StockAdjustmentRequest true "库存调整请求"
// @Success 200 {object} controller.Response{data=dto.StockLogResponse} "业务码为200代表调整成功"
// @Router /api/v1/inventory/stock/adjust [post]
func (c *InventoryController) AdjustStock(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "AdjustStock")
var req dto.StockAdjustmentRequest
const actionType = "调整原料库存"
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.inventoryService.AdjustStock(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层调整库存失败: %v", actionType, err)
if errors.Is(err, service.ErrInventoryRawMaterialNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "原料不存在", req)
}
if errors.Is(err, service.ErrInventoryInsufficientStock) {
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), actionType, "原料库存不足", req)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "调整库存失败: "+err.Error(), actionType, "服务层调整库存失败", req)
}
logger.Infof("%s: 库存调整成功, 原料ID: %d", actionType, resp.RawMaterialID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "库存调整成功", resp, actionType, "库存调整成功", resp)
}
// ListCurrentStock godoc
// @Summary 获取当前库存列表
// @Description 获取所有原料的当前库存列表,支持分页和过滤。
// @Tags 库存管理
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListCurrentStockRequest false "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListCurrentStockResponse} "业务码为200代表成功获取列表"
// @Router /api/v1/inventory/stock/current [get]
func (c *InventoryController) ListCurrentStock(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "ListCurrentStock")
const actionType = "获取当前库存列表"
var req dto.ListCurrentStockRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 查询参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "查询参数绑定失败", req)
}
resp, err := c.inventoryService.ListCurrentStock(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层获取当前库存列表失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取当前库存列表失败: "+err.Error(), actionType, "服务层获取当前库存列表失败", nil)
}
logger.Infof("%s: 获取当前库存列表成功, 数量: %d", actionType, len(resp.List))
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取当前库存列表成功", resp, actionType, "获取当前库存列表成功", resp)
}
// ListStockLogs godoc
// @Summary 获取库存变动日志
// @Description 获取原料库存变动历史记录,支持分页、过滤和时间范围查询。
// @Tags 库存管理
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListStockLogRequest false "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListStockLogResponse} "业务码为200代表成功获取列表"
// @Router /api/v1/inventory/stock/logs [get]
func (c *InventoryController) ListStockLogs(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "ListStockLogs")
const actionType = "获取库存变动日志"
var req dto.ListStockLogRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 查询参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "查询参数绑定失败", req)
}
resp, err := c.inventoryService.ListStockLogs(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层获取库存变动日志失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取库存变动日志失败: "+err.Error(), actionType, "服务层获取库存变动日志失败", nil)
}
logger.Infof("%s: 获取库存变动日志成功, 数量: %d", actionType, len(resp.List))
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取库存变动日志成功", resp, actionType, "获取库存变动日志成功", resp)
}

View File

@@ -0,0 +1,66 @@
package dto
import (
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
)
// ConvertCurrentStockToDTO 将原料及其最新库存日志转换为 CurrentStockResponse DTO
func ConvertCurrentStockToDTO(material *models.RawMaterial, latestLog *models.RawMaterialStockLog) *CurrentStockResponse {
if material == nil {
return nil
}
stock := float32(0)
lastUpdated := material.CreatedAt.Format(time.RFC3339) // 默认使用创建时间
if latestLog != nil {
stock = latestLog.AfterQuantity
lastUpdated = latestLog.HappenedAt.Format(time.RFC3339)
}
return &CurrentStockResponse{
RawMaterialID: material.ID,
RawMaterialName: material.Name,
Stock: stock,
LastUpdated: lastUpdated,
}
}
// ConvertStockLogToDTO 将 models.RawMaterialStockLog 转换为 StockLogResponse DTO
func ConvertStockLogToDTO(log *models.RawMaterialStockLog) *StockLogResponse {
if log == nil {
return nil
}
return &StockLogResponse{
ID: log.ID,
RawMaterialID: log.RawMaterialID,
RawMaterialName: log.RawMaterial.Name, // 假设 RawMaterial 已被预加载
ChangeAmount: log.ChangeAmount,
BeforeQuantity: log.BeforeQuantity,
AfterQuantity: log.AfterQuantity,
SourceType: string(log.SourceType),
SourceID: log.SourceID,
HappenedAt: log.HappenedAt,
Remarks: log.Remarks,
}
}
// ConvertStockLogListToDTO 将 []models.RawMaterialStockLog 转换为 ListStockLogResponse DTO
func ConvertStockLogListToDTO(logs []models.RawMaterialStockLog, total int64, page, pageSize int) *ListStockLogResponse {
logDTOs := make([]StockLogResponse, len(logs))
for i, log := range logs {
logDTOs[i] = *ConvertStockLogToDTO(&log)
}
return &ListStockLogResponse{
List: logDTOs,
Pagination: PaginationDTO{
Page: page,
PageSize: pageSize,
Total: total,
},
}
}

View File

@@ -0,0 +1,67 @@
package dto
import "time"
// =============================================================================================================
// 库存 (Inventory) 相关 DTO
// =============================================================================================================
// StockAdjustmentRequest 手动调整库存的请求体
type StockAdjustmentRequest struct {
RawMaterialID uint32 `json:"raw_material_id" validate:"required"` // 要调整的原料ID
ChangeAmount float32 `json:"change_amount" validate:"required,ne=0"` // 变动数量, 正数为入库, 负数为出库, 单位: g
Remarks string `json:"remarks" validate:"max=255"` // 备注
}
// CurrentStockResponse 单个原料及其当前库存的响应体
type CurrentStockResponse struct {
RawMaterialID uint32 `json:"raw_material_id"` // 原料ID
RawMaterialName string `json:"raw_material_name"` // 原料名称
Stock float32 `json:"stock"` // 当前库存量, 单位: g
LastUpdated string `json:"last_updated"` // 最后更新时间
}
// ListCurrentStockRequest 定义了获取当前库存列表的请求参数
type ListCurrentStockRequest struct {
Page int `json:"page" query:"page"` // 页码
PageSize int `json:"page_size" query:"page_size"` // 每页数量
RawMaterialName *string `json:"raw_material_name" query:"raw_material_name"` // 按原料名称模糊查询
OrderBy string `json:"order_by" query:"order_by"` // 排序字段, 例如 "stock DESC"
}
// ListCurrentStockResponse 是获取当前库存列表的响应结构
type ListCurrentStockResponse struct {
List []CurrentStockResponse `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// StockLogResponse 库存变动历史记录的响应体
type StockLogResponse struct {
ID uint32 `json:"id"`
RawMaterialID uint32 `json:"raw_material_id"`
RawMaterialName string `json:"raw_material_name"`
ChangeAmount float32 `json:"change_amount"`
BeforeQuantity float32 `json:"before_quantity"`
AfterQuantity float32 `json:"after_quantity"`
SourceType string `json:"source_type"`
SourceID *uint32 `json:"source_id,omitempty"`
HappenedAt time.Time `json:"happened_at"`
Remarks string `json:"remarks"`
}
// ListStockLogRequest 定义了获取库存变动历史的请求参数
type ListStockLogRequest struct {
Page int `json:"page" query:"page"` // 页码
PageSize int `json:"page_size" query:"page_size"` // 每页数量
RawMaterialID *uint32 `json:"raw_material_id" query:"raw_material_id"` // 按原料ID精确查询
SourceTypes []string `json:"source_types" query:"source_types"` // 按来源类型查询
StartTime *string `json:"start_time" query:"start_time"` // 开始时间 (RFC3339格式, e.g., "2023-01-01T00:00:00Z")
EndTime *string `json:"end_time" query:"end_time"` // 结束时间 (RFC3339格式)
OrderBy string `json:"order_by" query:"order_by"` // 排序字段
}
// ListStockLogResponse 是获取库存变动历史列表的响应结构
type ListStockLogResponse struct {
List []StockLogResponse `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}

View File

@@ -0,0 +1,157 @@
package service
import (
"context"
"errors"
"fmt"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/inventory"
"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"
)
// 定义库存应用服务特定的错误
var (
ErrInventoryRawMaterialNotFound = errors.New("原料不存在")
ErrInventoryInsufficientStock = errors.New("原料库存不足")
)
// InventoryService 定义了库存相关的应用服务接口
type InventoryService interface {
AdjustStock(ctx context.Context, req *dto.StockAdjustmentRequest) (*dto.StockLogResponse, error)
ListCurrentStock(ctx context.Context, req *dto.ListCurrentStockRequest) (*dto.ListCurrentStockResponse, error)
ListStockLogs(ctx context.Context, req *dto.ListStockLogRequest) (*dto.ListStockLogResponse, error)
}
// inventoryServiceImpl 是 InventoryService 接口的实现
type inventoryServiceImpl struct {
ctx context.Context
invSvc inventory.InventoryCoreService
rawMatRepo repository.RawMaterialRepository
}
// NewInventoryService 创建一个新的 InventoryService 实例
func NewInventoryService(ctx context.Context, invSvc inventory.InventoryCoreService, rawMatRepo repository.RawMaterialRepository) InventoryService {
return &inventoryServiceImpl{
ctx: ctx,
invSvc: invSvc,
rawMatRepo: rawMatRepo,
}
}
// AdjustStock 手动调整库存
func (s *inventoryServiceImpl) AdjustStock(ctx context.Context, req *dto.StockAdjustmentRequest) (*dto.StockLogResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "AdjustStock")
// 调用领域服务执行核心业务逻辑
log, err := s.invSvc.AdjustStock(serviceCtx, req.RawMaterialID, req.ChangeAmount, models.StockLogSourceManual, nil, req.Remarks)
if err != nil {
if errors.Is(err, inventory.ErrRawMaterialNotFound) {
return nil, ErrInventoryRawMaterialNotFound
}
if errors.Is(err, inventory.ErrInsufficientStock) {
return nil, ErrInventoryInsufficientStock
}
return nil, fmt.Errorf("调整库存失败: %w", err)
}
// 手动加载 RawMaterial 信息,因为 CreateRawMaterialStockLog 不会预加载它
rawMaterial, err := s.rawMatRepo.GetRawMaterialByID(serviceCtx, log.RawMaterialID)
if err != nil {
// 理论上不应该发生,因为 AdjustStock 内部已经检查过
return nil, fmt.Errorf("获取原料信息失败: %w", err)
}
log.RawMaterial = *rawMaterial
return dto.ConvertStockLogToDTO(log), nil
}
// ListCurrentStock 列出所有原料的当前库存
func (s *inventoryServiceImpl) ListCurrentStock(ctx context.Context, req *dto.ListCurrentStockRequest) (*dto.ListCurrentStockResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListCurrentStock")
// 1. 获取分页的原料列表
rawMatOpts := repository.RawMaterialListOptions{
Name: req.RawMaterialName,
OrderBy: req.OrderBy, // 注意:这里的排序可能需要调整,比如按原料名排序
}
rawMaterials, total, err := s.rawMatRepo.ListRawMaterials(serviceCtx, rawMatOpts, req.Page, req.PageSize)
if err != nil {
return nil, fmt.Errorf("获取原料列表失败: %w", err)
}
if len(rawMaterials) == 0 {
return &dto.ListCurrentStockResponse{
List: []dto.CurrentStockResponse{},
Pagination: dto.PaginationDTO{Page: req.Page, PageSize: req.PageSize, Total: total},
}, nil
}
// 2. 提取原料ID并批量获取它们的最新库存日志
materialIDs := make([]uint32, len(rawMaterials))
for i, rm := range rawMaterials {
materialIDs[i] = rm.ID
}
latestLogMap, err := s.rawMatRepo.BatchGetLatestStockLogsForMaterials(serviceCtx, materialIDs)
if err != nil {
return nil, fmt.Errorf("批量获取最新库存日志失败: %w", err)
}
// 3. 组合原料信息和库存信息
stockDTOs := make([]dto.CurrentStockResponse, len(rawMaterials))
for i, rm := range rawMaterials {
log, _ := latestLogMap[rm.ID] // 如果找不到log会是零值
stockDTOs[i] = *dto.ConvertCurrentStockToDTO(&rm, &log)
}
return &dto.ListCurrentStockResponse{
List: stockDTOs,
Pagination: dto.PaginationDTO{Page: req.Page, PageSize: req.PageSize, Total: total},
}, nil
}
// ListStockLogs 列出库存变动历史
func (s *inventoryServiceImpl) ListStockLogs(ctx context.Context, req *dto.ListStockLogRequest) (*dto.ListStockLogResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListStockLogs")
// 解析时间字符串
var startTime, endTime *time.Time
if req.StartTime != nil && *req.StartTime != "" {
t, err := time.Parse(time.RFC3339, *req.StartTime)
if err != nil {
return nil, fmt.Errorf("无效的开始时间格式: %w", err)
}
startTime = &t
}
if req.EndTime != nil && *req.EndTime != "" {
t, err := time.Parse(time.RFC3339, *req.EndTime)
if err != nil {
return nil, fmt.Errorf("无效的结束时间格式: %w", err)
}
endTime = &t
}
// 转换 source types
sourceTypes := make([]models.StockLogSourceType, len(req.SourceTypes))
for i, st := range req.SourceTypes {
sourceTypes[i] = models.StockLogSourceType(st)
}
opts := repository.StockLogListOptions{
RawMaterialID: req.RawMaterialID,
SourceTypes: sourceTypes,
StartTime: startTime,
EndTime: endTime,
OrderBy: req.OrderBy,
}
logs, total, err := s.invSvc.ListStockLogs(serviceCtx, opts, req.Page, req.PageSize)
if err != nil {
return nil, fmt.Errorf("获取库存日志列表失败: %w", err)
}
return dto.ConvertStockLogListToDTO(logs, total, req.Page, req.PageSize), nil
}