diff --git a/design/recipe-management/index.md b/design/recipe-management/index.md index e5bbe80..a1cbae1 100644 --- a/design/recipe-management/index.md +++ b/design/recipe-management/index.md @@ -61,4 +61,5 @@ http://git.huangwc.com/pig/pig-farm-controller/issues/66 11. 配方模型定义和仓库层增删改查方法 12. 配方领域层方法 13. 重构配方领域 -14. 配方增删改查服务层和控制器 \ No newline at end of file +14. 配方增删改查服务层和控制器 +15. 实现库存管理相关逻辑 \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go index e645141..fb30255 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -3267,6 +3267,205 @@ const docTemplate = `{ } } }, + "/api/v1/inventory/stock/adjust": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "手动调整指定原料的库存量。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "库存管理" + ], + "summary": "调整原料库存", + "parameters": [ + { + "description": "库存调整请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.StockAdjustmentRequest" + } + } + ], + "responses": { + "200": { + "description": "业务码为200代表调整成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/controller.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/dto.StockLogResponse" + } + } + } + ] + } + } + } + } + }, + "/api/v1/inventory/stock/current": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "获取所有原料的当前库存列表,支持分页和过滤。", + "produces": [ + "application/json" + ], + "tags": [ + "库存管理" + ], + "summary": "获取当前库存列表", + "parameters": [ + { + "type": "string", + "description": "排序字段, 例如 \"stock DESC\"", + "name": "order_by", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "按原料名称模糊查询", + "name": "raw_material_name", + "in": "query" + } + ], + "responses": { + "200": { + "description": "业务码为200代表成功获取列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/controller.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/dto.ListCurrentStockResponse" + } + } + } + ] + } + } + } + } + }, + "/api/v1/inventory/stock/logs": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "获取原料库存变动历史记录,支持分页、过滤和时间范围查询。", + "produces": [ + "application/json" + ], + "tags": [ + "库存管理" + ], + "summary": "获取库存变动日志", + "parameters": [ + { + "type": "string", + "description": "结束时间 (RFC3339格式)", + "name": "end_time", + "in": "query" + }, + { + "type": "string", + "description": "排序字段", + "name": "order_by", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量", + "name": "page_size", + "in": "query" + }, + { + "type": "integer", + "description": "按原料ID精确查询", + "name": "raw_material_id", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "按来源类型查询", + "name": "source_types", + "in": "query" + }, + { + "type": "string", + "description": "开始时间 (RFC3339格式, e.g., \"2023-01-01T00:00:00Z\")", + "name": "start_time", + "in": "query" + } + ], + "responses": { + "200": { + "description": "业务码为200代表成功获取列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/controller.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/dto.ListStockLogResponse" + } + } + } + ] + } + } + } + } + }, "/api/v1/monitor/device-command-logs": { "get": { "security": [ @@ -3448,6 +3647,7 @@ const docTemplate = `{ }, { "enum": [ + 7, -1, 0, 1, @@ -3457,12 +3657,12 @@ const docTemplate = `{ 5, -1, 5, - 6, - 7 + 6 ], "type": "integer", "format": "int32", "x-enum-varnames": [ + "_numLevels", "DebugLevel", "InfoLevel", "WarnLevel", @@ -3472,8 +3672,7 @@ const docTemplate = `{ "FatalLevel", "_minLevel", "_maxLevel", - "InvalidLevel", - "_numLevels" + "InvalidLevel" ], "name": "level", "in": "query" @@ -7062,6 +7261,27 @@ const docTemplate = `{ } } }, + "dto.CurrentStockResponse": { + "type": "object", + "properties": { + "last_updated": { + "description": "最后更新时间", + "type": "string" + }, + "raw_material_id": { + "description": "原料ID", + "type": "integer" + }, + "raw_material_name": { + "description": "原料名称", + "type": "string" + }, + "stock": { + "description": "当前库存量, 单位: g", + "type": "number" + } + } + }, "dto.DeleteDeviceThresholdAlarmDTO": { "type": "object", "required": [ @@ -7259,6 +7479,20 @@ const docTemplate = `{ } } }, + "dto.ListCurrentStockResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.CurrentStockResponse" + } + }, + "pagination": { + "$ref": "#/definitions/dto.PaginationDTO" + } + } + }, "dto.ListDeviceCommandLogResponse": { "type": "object", "properties": { @@ -7540,6 +7774,20 @@ const docTemplate = `{ } } }, + "dto.ListStockLogResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.StockLogResponse" + } + }, + "pagination": { + "$ref": "#/definitions/dto.PaginationDTO" + } + } + }, "dto.ListTaskExecutionLogResponse": { "type": "object", "properties": { @@ -8852,6 +9100,63 @@ const docTemplate = `{ } } }, + "dto.StockAdjustmentRequest": { + "type": "object", + "required": [ + "change_amount", + "raw_material_id" + ], + "properties": { + "change_amount": { + "description": "变动数量, 正数为入库, 负数为出库, 单位: g", + "type": "number" + }, + "raw_material_id": { + "description": "要调整的原料ID", + "type": "integer" + }, + "remarks": { + "description": "备注", + "type": "string", + "maxLength": 255 + } + } + }, + "dto.StockLogResponse": { + "type": "object", + "properties": { + "after_quantity": { + "type": "number" + }, + "before_quantity": { + "type": "number" + }, + "change_amount": { + "type": "number" + }, + "happened_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "raw_material_id": { + "type": "integer" + }, + "raw_material_name": { + "type": "string" + }, + "remarks": { + "type": "string" + }, + "source_id": { + "type": "integer" + }, + "source_type": { + "type": "string" + } + } + }, "dto.SubPlanResponse": { "type": "object", "properties": { @@ -10147,6 +10452,7 @@ const docTemplate = `{ "type": "integer", "format": "int32", "enum": [ + 7, -1, 0, 1, @@ -10156,10 +10462,10 @@ const docTemplate = `{ 5, -1, 5, - 6, - 7 + 6 ], "x-enum-varnames": [ + "_numLevels", "DebugLevel", "InfoLevel", "WarnLevel", @@ -10169,8 +10475,7 @@ const docTemplate = `{ "FatalLevel", "_minLevel", "_maxLevel", - "InvalidLevel", - "_numLevels" + "InvalidLevel" ] } }, diff --git a/docs/swagger.json b/docs/swagger.json index a01c4f3..64d21ae 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -3259,6 +3259,205 @@ } } }, + "/api/v1/inventory/stock/adjust": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "手动调整指定原料的库存量。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "库存管理" + ], + "summary": "调整原料库存", + "parameters": [ + { + "description": "库存调整请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.StockAdjustmentRequest" + } + } + ], + "responses": { + "200": { + "description": "业务码为200代表调整成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/controller.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/dto.StockLogResponse" + } + } + } + ] + } + } + } + } + }, + "/api/v1/inventory/stock/current": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "获取所有原料的当前库存列表,支持分页和过滤。", + "produces": [ + "application/json" + ], + "tags": [ + "库存管理" + ], + "summary": "获取当前库存列表", + "parameters": [ + { + "type": "string", + "description": "排序字段, 例如 \"stock DESC\"", + "name": "order_by", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "按原料名称模糊查询", + "name": "raw_material_name", + "in": "query" + } + ], + "responses": { + "200": { + "description": "业务码为200代表成功获取列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/controller.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/dto.ListCurrentStockResponse" + } + } + } + ] + } + } + } + } + }, + "/api/v1/inventory/stock/logs": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "获取原料库存变动历史记录,支持分页、过滤和时间范围查询。", + "produces": [ + "application/json" + ], + "tags": [ + "库存管理" + ], + "summary": "获取库存变动日志", + "parameters": [ + { + "type": "string", + "description": "结束时间 (RFC3339格式)", + "name": "end_time", + "in": "query" + }, + { + "type": "string", + "description": "排序字段", + "name": "order_by", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量", + "name": "page_size", + "in": "query" + }, + { + "type": "integer", + "description": "按原料ID精确查询", + "name": "raw_material_id", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "按来源类型查询", + "name": "source_types", + "in": "query" + }, + { + "type": "string", + "description": "开始时间 (RFC3339格式, e.g., \"2023-01-01T00:00:00Z\")", + "name": "start_time", + "in": "query" + } + ], + "responses": { + "200": { + "description": "业务码为200代表成功获取列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/controller.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/dto.ListStockLogResponse" + } + } + } + ] + } + } + } + } + }, "/api/v1/monitor/device-command-logs": { "get": { "security": [ @@ -3440,6 +3639,7 @@ }, { "enum": [ + 7, -1, 0, 1, @@ -3449,12 +3649,12 @@ 5, -1, 5, - 6, - 7 + 6 ], "type": "integer", "format": "int32", "x-enum-varnames": [ + "_numLevels", "DebugLevel", "InfoLevel", "WarnLevel", @@ -3464,8 +3664,7 @@ "FatalLevel", "_minLevel", "_maxLevel", - "InvalidLevel", - "_numLevels" + "InvalidLevel" ], "name": "level", "in": "query" @@ -7054,6 +7253,27 @@ } } }, + "dto.CurrentStockResponse": { + "type": "object", + "properties": { + "last_updated": { + "description": "最后更新时间", + "type": "string" + }, + "raw_material_id": { + "description": "原料ID", + "type": "integer" + }, + "raw_material_name": { + "description": "原料名称", + "type": "string" + }, + "stock": { + "description": "当前库存量, 单位: g", + "type": "number" + } + } + }, "dto.DeleteDeviceThresholdAlarmDTO": { "type": "object", "required": [ @@ -7251,6 +7471,20 @@ } } }, + "dto.ListCurrentStockResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.CurrentStockResponse" + } + }, + "pagination": { + "$ref": "#/definitions/dto.PaginationDTO" + } + } + }, "dto.ListDeviceCommandLogResponse": { "type": "object", "properties": { @@ -7532,6 +7766,20 @@ } } }, + "dto.ListStockLogResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.StockLogResponse" + } + }, + "pagination": { + "$ref": "#/definitions/dto.PaginationDTO" + } + } + }, "dto.ListTaskExecutionLogResponse": { "type": "object", "properties": { @@ -8844,6 +9092,63 @@ } } }, + "dto.StockAdjustmentRequest": { + "type": "object", + "required": [ + "change_amount", + "raw_material_id" + ], + "properties": { + "change_amount": { + "description": "变动数量, 正数为入库, 负数为出库, 单位: g", + "type": "number" + }, + "raw_material_id": { + "description": "要调整的原料ID", + "type": "integer" + }, + "remarks": { + "description": "备注", + "type": "string", + "maxLength": 255 + } + } + }, + "dto.StockLogResponse": { + "type": "object", + "properties": { + "after_quantity": { + "type": "number" + }, + "before_quantity": { + "type": "number" + }, + "change_amount": { + "type": "number" + }, + "happened_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "raw_material_id": { + "type": "integer" + }, + "raw_material_name": { + "type": "string" + }, + "remarks": { + "type": "string" + }, + "source_id": { + "type": "integer" + }, + "source_type": { + "type": "string" + } + } + }, "dto.SubPlanResponse": { "type": "object", "properties": { @@ -10139,6 +10444,7 @@ "type": "integer", "format": "int32", "enum": [ + 7, -1, 0, 1, @@ -10148,10 +10454,10 @@ 5, -1, 5, - 6, - 7 + 6 ], "x-enum-varnames": [ + "_numLevels", "DebugLevel", "InfoLevel", "WarnLevel", @@ -10161,8 +10467,7 @@ "FatalLevel", "_minLevel", "_maxLevel", - "InvalidLevel", - "_numLevels" + "InvalidLevel" ] } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index e52e437..db58d7f 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -462,6 +462,21 @@ definitions: example: newuser type: string type: object + dto.CurrentStockResponse: + properties: + last_updated: + description: 最后更新时间 + type: string + raw_material_id: + description: 原料ID + type: integer + raw_material_name: + description: 原料名称 + type: string + stock: + description: '当前库存量, 单位: g' + type: number + type: object dto.DeleteDeviceThresholdAlarmDTO: properties: sensor_type: @@ -590,6 +605,15 @@ definitions: pagination: $ref: '#/definitions/dto.PaginationDTO' type: object + dto.ListCurrentStockResponse: + properties: + list: + items: + $ref: '#/definitions/dto.CurrentStockResponse' + type: array + pagination: + $ref: '#/definitions/dto.PaginationDTO' + type: object dto.ListDeviceCommandLogResponse: properties: list: @@ -771,6 +795,15 @@ definitions: pagination: $ref: '#/definitions/dto.PaginationDTO' type: object + dto.ListStockLogResponse: + properties: + list: + items: + $ref: '#/definitions/dto.StockLogResponse' + type: array + pagination: + $ref: '#/definitions/dto.PaginationDTO' + type: object dto.ListTaskExecutionLogResponse: properties: list: @@ -1654,6 +1687,45 @@ definitions: required: - duration_minutes type: object + dto.StockAdjustmentRequest: + properties: + change_amount: + description: '变动数量, 正数为入库, 负数为出库, 单位: g' + type: number + raw_material_id: + description: 要调整的原料ID + type: integer + remarks: + description: 备注 + maxLength: 255 + type: string + required: + - change_amount + - raw_material_id + type: object + dto.StockLogResponse: + properties: + after_quantity: + type: number + before_quantity: + type: number + change_amount: + type: number + happened_at: + type: string + id: + type: integer + raw_material_id: + type: integer + raw_material_name: + type: string + remarks: + type: string + source_id: + type: integer + source_type: + type: string + type: object dto.SubPlanResponse: properties: child_plan: @@ -2606,6 +2678,7 @@ definitions: - PlanTypeFilterSystem zapcore.Level: enum: + - 7 - -1 - 0 - 1 @@ -2616,10 +2689,10 @@ definitions: - -1 - 5 - 6 - - 7 format: int32 type: integer x-enum-varnames: + - _numLevels - DebugLevel - InfoLevel - WarnLevel @@ -2630,7 +2703,6 @@ definitions: - _minLevel - _maxLevel - InvalidLevel - - _numLevels info: contact: email: divano@example.com @@ -4601,6 +4673,124 @@ paths: summary: 更新配方 tags: - 饲料管理-配方 + /api/v1/inventory/stock/adjust: + post: + consumes: + - application/json + description: 手动调整指定原料的库存量。 + parameters: + - description: 库存调整请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/dto.StockAdjustmentRequest' + produces: + - application/json + responses: + "200": + description: 业务码为200代表调整成功 + schema: + allOf: + - $ref: '#/definitions/controller.Response' + - properties: + data: + $ref: '#/definitions/dto.StockLogResponse' + type: object + security: + - BearerAuth: [] + summary: 调整原料库存 + tags: + - 库存管理 + /api/v1/inventory/stock/current: + get: + description: 获取所有原料的当前库存列表,支持分页和过滤。 + parameters: + - description: 排序字段, 例如 "stock DESC" + in: query + name: order_by + type: string + - description: 页码 + in: query + name: page + type: integer + - description: 每页数量 + in: query + name: page_size + type: integer + - description: 按原料名称模糊查询 + in: query + name: raw_material_name + type: string + produces: + - application/json + responses: + "200": + description: 业务码为200代表成功获取列表 + schema: + allOf: + - $ref: '#/definitions/controller.Response' + - properties: + data: + $ref: '#/definitions/dto.ListCurrentStockResponse' + type: object + security: + - BearerAuth: [] + summary: 获取当前库存列表 + tags: + - 库存管理 + /api/v1/inventory/stock/logs: + get: + description: 获取原料库存变动历史记录,支持分页、过滤和时间范围查询。 + parameters: + - description: 结束时间 (RFC3339格式) + in: query + name: end_time + type: string + - description: 排序字段 + in: query + name: order_by + type: string + - description: 页码 + in: query + name: page + type: integer + - description: 每页数量 + in: query + name: page_size + type: integer + - description: 按原料ID精确查询 + in: query + name: raw_material_id + type: integer + - collectionFormat: csv + description: 按来源类型查询 + in: query + items: + type: string + name: source_types + type: array + - description: 开始时间 (RFC3339格式, e.g., "2023-01-01T00:00:00Z") + in: query + name: start_time + type: string + produces: + - application/json + responses: + "200": + description: 业务码为200代表成功获取列表 + schema: + allOf: + - $ref: '#/definitions/controller.Response' + - properties: + data: + $ref: '#/definitions/dto.ListStockLogResponse' + type: object + security: + - BearerAuth: [] + summary: 获取库存变动日志 + tags: + - 库存管理 /api/v1/monitor/device-command-logs: get: description: 根据提供的过滤条件,分页获取设备命令日志 @@ -4699,6 +4889,7 @@ paths: name: end_time type: string - enum: + - 7 - -1 - 0 - 1 @@ -4709,12 +4900,12 @@ paths: - -1 - 5 - 6 - - 7 format: int32 in: query name: level type: integer x-enum-varnames: + - _numLevels - DebugLevel - InfoLevel - WarnLevel @@ -4725,7 +4916,6 @@ paths: - _minLevel - _maxLevel - InvalidLevel - - _numLevels - enum: - 邮件 - 企业微信 diff --git a/internal/app/api/api.go b/internal/app/api/api.go index 830c322..2e3b94e 100644 --- a/internal/app/api/api.go +++ b/internal/app/api/api.go @@ -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() // 设置所有路由 diff --git a/internal/app/api/router.go b/internal/app/api/router.go index ce940d0..4918118 100644 --- a/internal/app/api/router.go +++ b/internal/app/api/router.go @@ -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("所有接口注册成功") diff --git a/internal/app/controller/inventory/inventory_controller.go b/internal/app/controller/inventory/inventory_controller.go new file mode 100644 index 0000000..296d7ab --- /dev/null +++ b/internal/app/controller/inventory/inventory_controller.go @@ -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) +} diff --git a/internal/app/dto/inventory_converter.go b/internal/app/dto/inventory_converter.go new file mode 100644 index 0000000..6399b43 --- /dev/null +++ b/internal/app/dto/inventory_converter.go @@ -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, + }, + } +} diff --git a/internal/app/dto/inventory_dto.go b/internal/app/dto/inventory_dto.go new file mode 100644 index 0000000..0a306b0 --- /dev/null +++ b/internal/app/dto/inventory_dto.go @@ -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"` +} diff --git a/internal/app/service/inventory_service.go b/internal/app/service/inventory_service.go new file mode 100644 index 0000000..69cf231 --- /dev/null +++ b/internal/app/service/inventory_service.go @@ -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 +} diff --git a/internal/core/application.go b/internal/core/application.go index b21e53f..38c0bfd 100644 --- a/internal/core/application.go +++ b/internal/core/application.go @@ -69,6 +69,7 @@ func NewApplication(configPath string) (*Application, error) { appServices.pigAgeStageService, appServices.pigTypeService, appServices.recipeService, + appServices.inventoryService, infra.tokenGenerator, infra.lora.listenHandler, ) diff --git a/internal/core/component_initializers.go b/internal/core/component_initializers.go index 6878729..70ed680 100644 --- a/internal/core/component_initializers.go +++ b/internal/core/component_initializers.go @@ -9,6 +9,7 @@ import ( "git.huangwc.com/pig/pig-farm-controller/internal/app/webhook" "git.huangwc.com/pig/pig-farm-controller/internal/domain/alarm" "git.huangwc.com/pig/pig-farm-controller/internal/domain/device" + "git.huangwc.com/pig/pig-farm-controller/internal/domain/inventory" domain_notify "git.huangwc.com/pig/pig-farm-controller/internal/domain/notify" "git.huangwc.com/pig/pig-farm-controller/internal/domain/pig" "git.huangwc.com/pig/pig-farm-controller/internal/domain/plan" @@ -137,6 +138,7 @@ type DomainServices struct { notifyService domain_notify.Service alarmService alarm.AlarmService recipeService recipe.Service + inventoryService inventory.InventoryCoreService } // initDomainServices 初始化所有的领域服务。 @@ -233,6 +235,9 @@ func initDomainServices(ctx context.Context, cfg *config.Config, infra *Infrastr recipeCoreService, ) + // 库存管理 + inventoryService := inventory.NewInventoryCoreService(logs.AddCompName(baseCtx, "InventoryCoreService"), infra.repos.unitOfWork, infra.repos.rawMaterialRepo) + return &DomainServices{ pigPenTransferManager: pigPenTransferManager, pigTradeManager: pigTradeManager, @@ -246,6 +251,7 @@ func initDomainServices(ctx context.Context, cfg *config.Config, infra *Infrastr notifyService: notifyService, alarmService: alarmService, recipeService: recipeService, + inventoryService: inventoryService, }, nil } @@ -265,6 +271,7 @@ type AppServices struct { pigTypeService service.PigTypeService rawMaterialService service.RawMaterialService recipeService service.RecipeService + inventoryService service.InventoryService } // initAppServices 初始化所有的应用服务。 @@ -318,6 +325,7 @@ func initAppServices(ctx context.Context, infra *Infrastructure, domainServices pigTypeService := service.NewPigTypeService(logs.AddCompName(baseCtx, "PigTypeService"), domainServices.recipeService) rawMaterialService := service.NewRawMaterialService(logs.AddCompName(baseCtx, "RawMaterialService"), domainServices.recipeService) recipeService := service.NewRecipeService(logs.AddCompName(baseCtx, "RecipeService"), domainServices.recipeService) + inventoryService := service.NewInventoryService(logs.AddCompName(baseCtx, "InventoryService"), domainServices.inventoryService, infra.repos.rawMaterialRepo) return &AppServices{ pigFarmService: pigFarmService, @@ -334,6 +342,7 @@ func initAppServices(ctx context.Context, infra *Infrastructure, domainServices pigTypeService: pigTypeService, rawMaterialService: rawMaterialService, recipeService: recipeService, + inventoryService: inventoryService, } } diff --git a/internal/domain/inventory/inventory_service.go b/internal/domain/inventory/inventory_service.go new file mode 100644 index 0000000..fb0de26 --- /dev/null +++ b/internal/domain/inventory/inventory_service.go @@ -0,0 +1,170 @@ +package inventory + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "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" +) + +// 定义领域特定的错误 +var ( + ErrRawMaterialNotFound = errors.New("原料不存在") + ErrInsufficientStock = errors.New("原料库存不足") +) + +// InventoryCoreService 定义了库存领域的核心业务服务接口 +type InventoryCoreService interface { + // AdjustStock 调整指定原料的库存 + AdjustStock(ctx context.Context, rawMaterialID uint32, changeAmount float32, sourceType models.StockLogSourceType, sourceID *uint32, remarks string) (*models.RawMaterialStockLog, error) + // GetCurrentStock 获取单个原料的当前库存量 + GetCurrentStock(ctx context.Context, rawMaterialID uint32) (float32, error) + // BatchGetCurrentStock 批量获取多个原料的当前库存量 + BatchGetCurrentStock(ctx context.Context, rawMaterialIDs []uint32) (map[uint32]float32, error) + // ListStockLogs 分页查询库存变动日志 + ListStockLogs(ctx context.Context, opts repository.StockLogListOptions, page, pageSize int) ([]models.RawMaterialStockLog, int64, error) +} + +// inventoryCoreServiceImpl 是 InventoryCoreService 的实现 +type inventoryCoreServiceImpl struct { + ctx context.Context + uow repository.UnitOfWork + rawMatRepo repository.RawMaterialRepository + + // 全局库存调整锁,确保所有 AdjustStock 操作串行执行 + adjustStockMutex sync.Mutex +} + +// NewInventoryCoreService 创建一个新的 InventoryCoreService 实例 +func NewInventoryCoreService(ctx context.Context, uow repository.UnitOfWork, rawMatRepo repository.RawMaterialRepository) InventoryCoreService { + return &inventoryCoreServiceImpl{ + ctx: ctx, + uow: uow, + rawMatRepo: rawMatRepo, + } +} + +// AdjustStock 调整指定原料的库存 +func (s *inventoryCoreServiceImpl) AdjustStock(ctx context.Context, rawMaterialID uint32, changeAmount float32, sourceType models.StockLogSourceType, sourceID *uint32, remarks string) (*models.RawMaterialStockLog, error) { + serviceCtx := logs.AddFuncName(ctx, s.ctx, "AdjustStock") + + // 使用全局锁确保所有库存调整操作串行执行 + s.adjustStockMutex.Lock() + defer s.adjustStockMutex.Unlock() + + var createdLog *models.RawMaterialStockLog + + err := s.uow.ExecuteInTransaction(serviceCtx, func(tx *gorm.DB) error { + // 在事务中创建 RawMaterialRepository 的新实例 + txRawMatRepo := repository.NewGormRawMaterialRepository(serviceCtx, tx) + + // 1. 检查原料是否存在 + _, err := txRawMatRepo.GetRawMaterialByID(serviceCtx, rawMaterialID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrRawMaterialNotFound + } + return fmt.Errorf("检查原料是否存在时出错: %w", err) + } + + // 2. 获取当前库存 (在程序锁的保护下,这里是安全的) + latestLog, err := txRawMatRepo.GetLatestRawMaterialStockLog(serviceCtx, rawMaterialID) + if err != nil { + return fmt.Errorf("获取最新库存日志失败: %w", err) + } + + var beforeQuantity float32 = 0 + if latestLog != nil { + beforeQuantity = latestLog.AfterQuantity + } + + // 3. 计算新库存并检查是否充足 + afterQuantity := beforeQuantity + changeAmount + if afterQuantity < 0 { + return ErrInsufficientStock + } + + // 4. 创建新的库存日志 + newLog := &models.RawMaterialStockLog{ + RawMaterialID: rawMaterialID, + ChangeAmount: changeAmount, + BeforeQuantity: beforeQuantity, + AfterQuantity: afterQuantity, + SourceType: sourceType, + SourceID: sourceID, + HappenedAt: time.Now(), + Remarks: remarks, + } + + if err := txRawMatRepo.CreateRawMaterialStockLog(serviceCtx, newLog); err != nil { + return fmt.Errorf("创建库存日志失败: %w", err) + } + + createdLog = newLog + return nil + }) + + if err != nil { + return nil, err // 直接返回事务中发生的错误 + } + + return createdLog, nil +} + +// GetCurrentStock 获取单个原料的当前库存量 +func (s *inventoryCoreServiceImpl) GetCurrentStock(ctx context.Context, rawMaterialID uint32) (float32, error) { + serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetCurrentStock") + + latestLog, err := s.rawMatRepo.GetLatestRawMaterialStockLog(serviceCtx, rawMaterialID) + if err != nil { + return 0, fmt.Errorf("获取最新库存日志失败: %w", err) + } + + if latestLog == nil { + // 如果没有日志,说明从未入库,库存为0 + return 0, nil + } + + return latestLog.AfterQuantity, nil +} + +// BatchGetCurrentStock 批量获取多个原料的当前库存量 +func (s *inventoryCoreServiceImpl) BatchGetCurrentStock(ctx context.Context, rawMaterialIDs []uint32) (map[uint32]float32, error) { + serviceCtx := logs.AddFuncName(ctx, s.ctx, "BatchGetCurrentStock") + + logMap, err := s.rawMatRepo.BatchGetLatestStockLogsForMaterials(serviceCtx, rawMaterialIDs) + if err != nil { + return nil, fmt.Errorf("批量获取最新库存日志失败: %w", err) + } + + stockMap := make(map[uint32]float32, len(rawMaterialIDs)) + for _, id := range rawMaterialIDs { + if log, ok := logMap[id]; ok { + stockMap[id] = log.AfterQuantity + } else { + // 如果某个原料在 logMap 中不存在,说明它没有任何库存记录,库存为0 + stockMap[id] = 0 + } + } + + return stockMap, nil +} + +// ListStockLogs 分页查询库存变动日志 +func (s *inventoryCoreServiceImpl) ListStockLogs(ctx context.Context, opts repository.StockLogListOptions, page, pageSize int) ([]models.RawMaterialStockLog, int64, error) { + serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListStockLogs") + + logs, total, err := s.rawMatRepo.ListStockLogs(serviceCtx, opts, page, pageSize) + if err != nil { + return nil, 0, fmt.Errorf("获取库存日志列表失败: %w", err) + } + + return logs, total, nil +} diff --git a/internal/infra/repository/raw_material_repository.go b/internal/infra/repository/raw_material_repository.go index 1862ba4..43df119 100644 --- a/internal/infra/repository/raw_material_repository.go +++ b/internal/infra/repository/raw_material_repository.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" "git.huangwc.com/pig/pig-farm-controller/internal/infra/models" @@ -18,6 +19,16 @@ type RawMaterialListOptions struct { OrderBy string } +// StockLogListOptions 定义了查询库存日志列表时的筛选条件 +type StockLogListOptions struct { + RawMaterialID *uint32 + RawMaterialName *string + SourceTypes []models.StockLogSourceType + StartTime *time.Time + EndTime *time.Time + OrderBy string +} + // RawMaterialRepository 定义了与原料相关的数据库操作接口 type RawMaterialRepository interface { CreateRawMaterial(ctx context.Context, rawMaterial *models.RawMaterial) error @@ -32,6 +43,10 @@ type RawMaterialRepository interface { // 库存日志相关方法 CreateRawMaterialStockLog(ctx context.Context, log *models.RawMaterialStockLog) error GetLatestRawMaterialStockLog(ctx context.Context, rawMaterialID uint32) (*models.RawMaterialStockLog, error) + // BatchGetLatestStockLogsForMaterials 批量获取一组原料的最新库存日志 + BatchGetLatestStockLogsForMaterials(ctx context.Context, materialIDs []uint32) (map[uint32]models.RawMaterialStockLog, error) + // ListStockLogs 分页列出库存变动日志 + ListStockLogs(ctx context.Context, opts StockLogListOptions, page, pageSize int) ([]models.RawMaterialStockLog, int64, error) } // gormRawMaterialRepository 是 RawMaterialRepository 的 GORM 实现 @@ -219,3 +234,87 @@ func (r *gormRawMaterialRepository) GetLatestRawMaterialStockLog(ctx context.Con } return &latestLog, nil } + +// BatchGetLatestStockLogsForMaterials 批量获取一组原料的最新库存日志 +func (r *gormRawMaterialRepository) BatchGetLatestStockLogsForMaterials(ctx context.Context, materialIDs []uint32) (map[uint32]models.RawMaterialStockLog, error) { + repoCtx := logs.AddFuncName(ctx, r.ctx, "BatchGetLatestStockLogsForMaterials") + if len(materialIDs) == 0 { + return make(map[uint32]models.RawMaterialStockLog), nil + } + + var latestLogs []models.RawMaterialStockLog + + // 使用窗口函数 ROW_NUMBER() 来为每个原料的日志分区,并按时间倒序排名。 + // 这样可以高效地一次性查询出每个原料的最新一条日志。 + subQuery := r.db.Model(&models.RawMaterialStockLog{}). + Select("*, ROW_NUMBER() OVER(PARTITION BY raw_material_id ORDER BY happened_at DESC, id DESC) as rn"). + Where("raw_material_id IN ?", materialIDs) + + err := r.db.WithContext(repoCtx). + Table("(?) as sub", subQuery). + Where("rn = 1"). + Find(&latestLogs).Error + + if err != nil { + return nil, fmt.Errorf("批量获取最新库存日志失败: %w", err) + } + + // 将结果转换为 map[uint32]models.RawMaterialStockLog 以方便查找 + logMap := make(map[uint32]models.RawMaterialStockLog, len(latestLogs)) + for _, log := range latestLogs { + logMap[log.RawMaterialID] = log + } + + return logMap, nil +} + +// ListStockLogs 分页列出库存变动日志 +func (r *gormRawMaterialRepository) ListStockLogs(ctx context.Context, opts StockLogListOptions, page, pageSize int) ([]models.RawMaterialStockLog, int64, error) { + repoCtx := logs.AddFuncName(ctx, r.ctx, "ListStockLogs") + var logs []models.RawMaterialStockLog + var total int64 + + db := r.db.WithContext(repoCtx).Model(&models.RawMaterialStockLog{}) + + // 应用筛选条件 + if opts.RawMaterialID != nil { + db = db.Where("raw_material_id = ?", *opts.RawMaterialID) + } + + // 新增:按原料名称模糊搜索 + if opts.RawMaterialName != nil && *opts.RawMaterialName != "" { + // 使用子查询找到匹配的原料ID + subQuery := r.db.Model(&models.RawMaterial{}).Select("id").Where("name LIKE ?", "%"+*opts.RawMaterialName+"%") + db = db.Where("raw_material_id IN (?)", subQuery) + } + + if len(opts.SourceTypes) > 0 { + db = db.Where("source_type IN ?", opts.SourceTypes) + } + if opts.StartTime != nil { + db = db.Where("happened_at >= ?", *opts.StartTime) + } + if opts.EndTime != nil { + db = db.Where("happened_at <= ?", *opts.EndTime) + } + + // 首先计算总数 + if err := db.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("统计库存日志总数失败: %w", err) + } + + // 然后应用排序、分页并获取数据 + if opts.OrderBy != "" { + db = db.Order(opts.OrderBy) + } else { + // 默认排序 + db = db.Order("happened_at DESC, id DESC") + } + + offset := (page - 1) * pageSize + if err := db.Preload("RawMaterial").Offset(offset).Limit(pageSize).Find(&logs).Error; err != nil { + return nil, 0, fmt.Errorf("查询库存日志列表失败: %w", err) + } + + return logs, total, nil +} diff --git a/project_structure.txt b/project_structure.txt index c1e1dee..01895d2 100644 --- a/project_structure.txt +++ b/project_structure.txt @@ -37,7 +37,7 @@ design/archive/2025-11-05-provide-logger-with-mothed/task-webhook.md design/archive/2025-11-06-health-check-routing/index.md design/archive/2025-11-06-system-plan-continuously-triggered/index.md design/archive/2025-11-10-exceeding-threshold-alarm/index.md -design/archive/recipe-management/index.md +design/recipe-management/index.md docs/docs.go docs/swagger.json docs/swagger.yaml @@ -55,6 +55,7 @@ internal/app/controller/feed/pig_type_controller.go internal/app/controller/feed/raw_material_controller.go internal/app/controller/feed/recipe_controller.go internal/app/controller/health/health_controller.go +internal/app/controller/inventory/inventory_controller.go internal/app/controller/management/controller_helpers.go internal/app/controller/management/pig_batch_controller.go internal/app/controller/management/pig_batch_health_controller.go @@ -72,6 +73,8 @@ internal/app/dto/device_dto.go internal/app/dto/dto.go internal/app/dto/feed_converter.go internal/app/dto/feed_dto.go +internal/app/dto/inventory_converter.go +internal/app/dto/inventory_dto.go internal/app/dto/monitor_converter.go internal/app/dto/monitor_dto.go internal/app/dto/notification_converter.go @@ -85,6 +88,7 @@ internal/app/middleware/audit.go internal/app/middleware/auth.go internal/app/service/audit_service.go internal/app/service/device_service.go +internal/app/service/inventory_service.go internal/app/service/monitor_service.go internal/app/service/nutrient_service.go internal/app/service/pig_age_stage_service.go @@ -108,6 +112,7 @@ internal/core/data_initializer.go internal/domain/alarm/alarm_service.go internal/domain/device/device_service.go internal/domain/device/general_device_service.go +internal/domain/inventory/inventory_service.go internal/domain/notify/notify.go internal/domain/pig/pen_transfer_manager.go internal/domain/pig/pig_batch_service.go