diff --git a/db/queries/dashboard.sql b/db/queries/dashboard.sql index ceee7c3..cf50736 100644 --- a/db/queries/dashboard.sql +++ b/db/queries/dashboard.sql @@ -42,3 +42,24 @@ JOIN cabinets cab ON s.cabinet_id = cab.id JOIN rooms r ON cab.room_id = r.id LEFT JOIN component_items ci ON c.id = ci.container_id WHERE sqlc.narg('warehouse_id')::bigint IS NULL OR r.warehouse_id = sqlc.narg('warehouse_id')::bigint; + +-- name: GetStockAlerts :many +SELECT c.id, c.name, c.unit, c.total_quantity, c.min_quantity, c.component_type_id, + ct.name AS component_type_name +FROM components c +LEFT JOIN component_types ct ON c.component_type_id = ct.id +WHERE c.total_quantity <= c.min_quantity +ORDER BY (c.total_quantity - c.min_quantity) ASC; + +-- name: GetAnomalyItems :many +SELECT ci.id, ci.component_id, ci.container_id, ci.quantity, ci.status, ci.created_at, ci.updated_at, + c.name AS component_name, c.unit AS component_unit +FROM component_items ci +JOIN components c ON ci.component_id = c.id +JOIN containers con ON ci.container_id = con.id +JOIN shelves s ON con.shelf_id = s.id +JOIN cabinets cab ON s.cabinet_id = cab.id +JOIN rooms r ON cab.room_id = r.id +WHERE ci.status != 'normal' + AND (sqlc.narg('warehouse_id')::bigint IS NULL OR r.warehouse_id = sqlc.narg('warehouse_id')::bigint) +ORDER BY ci.updated_at DESC; diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 6999fb2..fea1249 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -2189,6 +2189,108 @@ const docTemplate = `{ } } }, + "/v1/dashboard/anomalies": { + "get": { + "description": "Retrieve list of component items with abnormal status (damaged, expired, long_unused, pending_inspection)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "Get anomaly items", + "parameters": [ + { + "type": "integer", + "description": "Filter by warehouse ID", + "name": "warehouse_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.SuccessResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.AnomalyItem" + } + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + } + } + } + }, + "/v1/dashboard/stock-alerts": { + "get": { + "description": "Retrieve list of components that are low on stock (total_quantity \u003c= min_quantity)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "Get stock alerts", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.SuccessResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.StockAlert" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + } + } + } + }, "/v1/dashboard/summary": { "get": { "description": "Retrieve dashboard summary with key statistics", @@ -2202,6 +2304,14 @@ const docTemplate = `{ "dashboard" ], "summary": "Get dashboard summary", + "parameters": [ + { + "type": "integer", + "description": "Filter by warehouse ID", + "name": "warehouse_id", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -3904,6 +4014,38 @@ const docTemplate = `{ } } }, + "models.AnomalyItem": { + "type": "object", + "properties": { + "componentId": { + "type": "integer" + }, + "componentName": { + "type": "string" + }, + "componentUnit": { + "type": "string" + }, + "containerId": { + "type": "integer" + }, + "createdAt": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "quantity": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, "models.Cabinet": { "type": "object", "properties": { @@ -4322,6 +4464,32 @@ const docTemplate = `{ } } }, + "models.StockAlert": { + "type": "object", + "properties": { + "componentTypeId": { + "type": "integer" + }, + "componentTypeName": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "minQuantity": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "totalQuantity": { + "type": "integer" + }, + "unit": { + "type": "string" + } + } + }, "models.TodayInvoiceCount": { "type": "object", "properties": { diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index a32f35d..9e53ed9 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -2183,6 +2183,108 @@ } } }, + "/v1/dashboard/anomalies": { + "get": { + "description": "Retrieve list of component items with abnormal status (damaged, expired, long_unused, pending_inspection)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "Get anomaly items", + "parameters": [ + { + "type": "integer", + "description": "Filter by warehouse ID", + "name": "warehouse_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.SuccessResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.AnomalyItem" + } + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + } + } + } + }, + "/v1/dashboard/stock-alerts": { + "get": { + "description": "Retrieve list of components that are low on stock (total_quantity \u003c= min_quantity)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "Get stock alerts", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.SuccessResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.StockAlert" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + } + } + } + }, "/v1/dashboard/summary": { "get": { "description": "Retrieve dashboard summary with key statistics", @@ -2196,6 +2298,14 @@ "dashboard" ], "summary": "Get dashboard summary", + "parameters": [ + { + "type": "integer", + "description": "Filter by warehouse ID", + "name": "warehouse_id", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -3898,6 +4008,38 @@ } } }, + "models.AnomalyItem": { + "type": "object", + "properties": { + "componentId": { + "type": "integer" + }, + "componentName": { + "type": "string" + }, + "componentUnit": { + "type": "string" + }, + "containerId": { + "type": "integer" + }, + "createdAt": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "quantity": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, "models.Cabinet": { "type": "object", "properties": { @@ -4316,6 +4458,32 @@ } } }, + "models.StockAlert": { + "type": "object", + "properties": { + "componentTypeId": { + "type": "integer" + }, + "componentTypeName": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "minQuantity": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "totalQuantity": { + "type": "integer" + }, + "unit": { + "type": "string" + } + } + }, "models.TodayInvoiceCount": { "type": "object", "properties": { diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 964f0d1..2b57e00 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -26,6 +26,27 @@ definitions: priority: type: integer type: object + models.AnomalyItem: + properties: + componentId: + type: integer + componentName: + type: string + componentUnit: + type: string + containerId: + type: integer + createdAt: + type: string + id: + type: integer + quantity: + type: integer + status: + type: string + updatedAt: + type: string + type: object models.Cabinet: properties: createdAt: @@ -300,6 +321,23 @@ definitions: updatedAt: type: string type: object + models.StockAlert: + properties: + componentTypeId: + type: integer + componentTypeName: + type: string + id: + type: integer + minQuantity: + type: integer + name: + type: string + totalQuantity: + type: integer + unit: + type: string + type: object models.TodayInvoiceCount: properties: count: @@ -2416,11 +2454,79 @@ paths: summary: Update container tags: - container + /v1/dashboard/anomalies: + get: + consumes: + - application/json + description: Retrieve list of component items with abnormal status (damaged, + expired, long_unused, pending_inspection) + parameters: + - description: Filter by warehouse ID + in: query + name: warehouse_id + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.SuccessResponse' + - properties: + data: + items: + $ref: '#/definitions/models.AnomalyItem' + type: array + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.ErrorResponse' + summary: Get anomaly items + tags: + - dashboard + /v1/dashboard/stock-alerts: + get: + consumes: + - application/json + description: Retrieve list of components that are low on stock (total_quantity + <= min_quantity) + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.SuccessResponse' + - properties: + data: + items: + $ref: '#/definitions/models.StockAlert' + type: array + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.ErrorResponse' + summary: Get stock alerts + tags: + - dashboard /v1/dashboard/summary: get: consumes: - application/json description: Retrieve dashboard summary with key statistics + parameters: + - description: Filter by warehouse ID + in: query + name: warehouse_id + type: integer produces: - application/json responses: diff --git a/internal/mapper/dashboard_mapper.go b/internal/mapper/dashboard_mapper.go index 900874c..2b3307f 100644 --- a/internal/mapper/dashboard_mapper.go +++ b/internal/mapper/dashboard_mapper.go @@ -32,3 +32,29 @@ func ToDomainContainerStats(r db.GetContainerStatsRow) models.ContainerStats { EmptyContainers: int64(r.EmptyContainers), } } + +func ToDomainStockAlert(r db.GetStockAlertsRow) models.StockAlert { + return models.StockAlert{ + ID: r.ID, + Name: r.Name, + Unit: r.Unit, + TotalQuantity: r.TotalQuantity, + MinQuantity: r.MinQuantity, + ComponentTypeID: r.ComponentTypeID, + ComponentTypeName: r.ComponentTypeName.String, + } +} + +func ToDomainAnomalyItem(r db.GetAnomalyItemsRow) models.AnomalyItem { + return models.AnomalyItem{ + ID: r.ID, + ComponentID: r.ComponentID, + ContainerID: r.ContainerID, + Quantity: r.Quantity, + Status: string(r.Status), + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + ComponentName: r.ComponentName, + ComponentUnit: r.ComponentUnit, + } +} diff --git a/internal/models/dashboard_model.go b/internal/models/dashboard_model.go index e3b4eef..e0ed116 100644 --- a/internal/models/dashboard_model.go +++ b/internal/models/dashboard_model.go @@ -1,5 +1,7 @@ package models +import "time" + type TotalComponentStats struct { TotalTypes int64 `json:"totalTypes"` TotalQuantity int64 `json:"totalQuantity"` @@ -28,3 +30,25 @@ type DashboardSummary struct { TodayInvoices []TodayInvoiceCount `json:"todayInvoices"` EmptyContainers ContainerStats `json:"emptyContainers"` } + +type StockAlert struct { + ID int64 `json:"id"` + Name string `json:"name"` + Unit string `json:"unit"` + TotalQuantity int32 `json:"totalQuantity"` + MinQuantity int32 `json:"minQuantity"` + ComponentTypeID int64 `json:"componentTypeId"` + ComponentTypeName string `json:"componentTypeName"` +} + +type AnomalyItem struct { + ID int64 `json:"id"` + ComponentID int64 `json:"componentId"` + ContainerID int64 `json:"containerId"` + Quantity int32 `json:"quantity"` + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + ComponentName string `json:"componentName"` + ComponentUnit string `json:"componentUnit"` +} diff --git a/internal/repositories/dashboard_repository.go b/internal/repositories/dashboard_repository.go index e08e214..dede458 100644 --- a/internal/repositories/dashboard_repository.go +++ b/internal/repositories/dashboard_repository.go @@ -59,3 +59,27 @@ func GetDashboardSummary(ctx context.Context, queries *db.Queries, warehouseID p EmptyContainers: mapper.ToDomainContainerStats(containerStats), }, nil } + +func GetStockAlerts(ctx context.Context, queries *db.Queries) ([]models.StockAlert, error) { + results, err := queries.GetStockAlerts(ctx) + if err != nil { + return nil, err + } + items := make([]models.StockAlert, 0, len(results)) + for _, r := range results { + items = append(items, mapper.ToDomainStockAlert(r)) + } + return items, nil +} + +func GetAnomalyItems(ctx context.Context, queries *db.Queries, warehouseID pgtype.Int8) ([]models.AnomalyItem, error) { + results, err := queries.GetAnomalyItems(ctx, warehouseID) + if err != nil { + return nil, err + } + items := make([]models.AnomalyItem, 0, len(results)) + for _, r := range results { + items = append(items, mapper.ToDomainAnomalyItem(r)) + } + return items, nil +} diff --git a/internal/routers/router.go b/internal/routers/router.go index fd074f9..8b3bc7b 100644 --- a/internal/routers/router.go +++ b/internal/routers/router.go @@ -156,6 +156,8 @@ func NewRouter() *gin.Engine { dashboard := protected.Group(constants.API_GROUP_DASHBOARD) { dashboard.GET("/summary", utils.AsyncHandler(services.DashboardSummary)) + dashboard.GET("/stock-alerts", utils.AsyncHandler(services.DashboardStockAlerts)) + dashboard.GET("/anomalies", utils.AsyncHandler(services.DashboardAnomalies)) } } } diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go index 235535b..3de619f 100644 --- a/internal/services/dashboard_service.go +++ b/internal/services/dashboard_service.go @@ -41,3 +41,53 @@ func DashboardSummary(c *gin.Context) error { response.Ok(c, "Success", summary) return nil } + +// @Summary Get stock alerts +// @Description Retrieve list of components that are low on stock (total_quantity <= min_quantity) +// @Tags dashboard +// @Accept json +// @Produce json +// @Success 200 {object} response.SuccessResponse{data=[]models.StockAlert} +// @Failure 500 {object} response.ErrorResponse +// @Router /v1/dashboard/stock-alerts [get] +func DashboardStockAlerts(c *gin.Context) error { + alerts, err := repositories.GetStockAlerts(c.Request.Context(), global.Queries) + if err != nil { + log.Err(err).Msg("Error when Get Stock Alerts") + response.InternalServerError(c, http.StatusInternalServerError, "Failed to get stock alerts") + return nil + } + response.Ok(c, "Success", alerts) + return nil +} + +// @Summary Get anomaly items +// @Description Retrieve list of component items with abnormal status (damaged, expired, long_unused, pending_inspection) +// @Tags dashboard +// @Accept json +// @Produce json +// @Param warehouse_id query int false "Filter by warehouse ID" +// @Success 200 {object} response.SuccessResponse{data=[]models.AnomalyItem} +// @Failure 400 {object} response.ErrorResponse +// @Failure 500 {object} response.ErrorResponse +// @Router /v1/dashboard/anomalies [get] +func DashboardAnomalies(c *gin.Context) error { + var warehouseID pgtype.Int8 + if raw := c.Query("warehouse_id"); raw != "" { + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + response.BadRequestError(c, http.StatusBadRequest, "Invalid warehouse_id") + return nil + } + warehouseID = pgtype.Int8{Int64: id, Valid: true} + } + + anomalies, err := repositories.GetAnomalyItems(c.Request.Context(), global.Queries, warehouseID) + if err != nil { + log.Err(err).Msg("Error when Get Anomaly Items") + response.InternalServerError(c, http.StatusInternalServerError, "Failed to get anomaly items") + return nil + } + response.Ok(c, "Success", anomalies) + return nil +} diff --git a/sqlc_gen/dashboard.sql.go b/sqlc_gen/dashboard.sql.go index 45c9163..cb42caa 100644 --- a/sqlc_gen/dashboard.sql.go +++ b/sqlc_gen/dashboard.sql.go @@ -7,6 +7,7 @@ package db import ( "context" + "time" "github.com/jackc/pgx/v5/pgtype" ) @@ -72,6 +73,62 @@ func (q *Queries) GetAbnormalItemCounts(ctx context.Context, warehouseID pgtype. return items, nil } +const getAnomalyItems = `-- name: GetAnomalyItems :many +SELECT ci.id, ci.component_id, ci.container_id, ci.quantity, ci.status, ci.created_at, ci.updated_at, + c.name AS component_name, c.unit AS component_unit +FROM component_items ci +JOIN components c ON ci.component_id = c.id +JOIN containers con ON ci.container_id = con.id +JOIN shelves s ON con.shelf_id = s.id +JOIN cabinets cab ON s.cabinet_id = cab.id +JOIN rooms r ON cab.room_id = r.id +WHERE ci.status != 'normal' + AND ($1::bigint IS NULL OR r.warehouse_id = $1::bigint) +ORDER BY ci.updated_at DESC +` + +type GetAnomalyItemsRow struct { + ID int64 `db:"id" json:"id"` + ComponentID int64 `db:"component_id" json:"componentId"` + ContainerID int64 `db:"container_id" json:"containerId"` + Quantity int32 `db:"quantity" json:"quantity"` + Status ComponentItemStatusEnum `db:"status" json:"status"` + CreatedAt time.Time `db:"created_at" json:"createdAt"` + UpdatedAt time.Time `db:"updated_at" json:"updatedAt"` + ComponentName string `db:"component_name" json:"componentName"` + ComponentUnit string `db:"component_unit" json:"componentUnit"` +} + +func (q *Queries) GetAnomalyItems(ctx context.Context, warehouseID pgtype.Int8) ([]GetAnomalyItemsRow, error) { + rows, err := q.db.Query(ctx, getAnomalyItems, warehouseID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAnomalyItemsRow + for rows.Next() { + var i GetAnomalyItemsRow + if err := rows.Scan( + &i.ID, + &i.ComponentID, + &i.ContainerID, + &i.Quantity, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.ComponentName, + &i.ComponentUnit, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getContainerStats = `-- name: GetContainerStats :one SELECT COUNT(*) AS total_containers, @@ -96,6 +153,53 @@ func (q *Queries) GetContainerStats(ctx context.Context, warehouseID pgtype.Int8 return i, err } +const getStockAlerts = `-- name: GetStockAlerts :many +SELECT c.id, c.name, c.unit, c.total_quantity, c.min_quantity, c.component_type_id, + ct.name AS component_type_name +FROM components c +LEFT JOIN component_types ct ON c.component_type_id = ct.id +WHERE c.total_quantity <= c.min_quantity +ORDER BY (c.total_quantity - c.min_quantity) ASC +` + +type GetStockAlertsRow struct { + ID int64 `db:"id" json:"id"` + Name string `db:"name" json:"name"` + Unit string `db:"unit" json:"unit"` + TotalQuantity int32 `db:"total_quantity" json:"totalQuantity"` + MinQuantity int32 `db:"min_quantity" json:"minQuantity"` + ComponentTypeID int64 `db:"component_type_id" json:"componentTypeId"` + ComponentTypeName pgtype.Text `db:"component_type_name" json:"componentTypeName"` +} + +func (q *Queries) GetStockAlerts(ctx context.Context) ([]GetStockAlertsRow, error) { + rows, err := q.db.Query(ctx, getStockAlerts) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetStockAlertsRow + for rows.Next() { + var i GetStockAlertsRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Unit, + &i.TotalQuantity, + &i.MinQuantity, + &i.ComponentTypeID, + &i.ComponentTypeName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getTodayInvoiceCounts = `-- name: GetTodayInvoiceCounts :many SELECT type, COUNT(*) AS count FROM invoices diff --git a/sqlc_gen/querier.go b/sqlc_gen/querier.go index 4eb1c02..592c314 100644 --- a/sqlc_gen/querier.go +++ b/sqlc_gen/querier.go @@ -51,6 +51,7 @@ type Querier interface { FindComponentItem(ctx context.Context, componentid int64) ([]FindComponentItemRow, error) GetAbnormalItemCounts(ctx context.Context, warehouseID pgtype.Int8) ([]GetAbnormalItemCountsRow, error) GetAlternativeComponentByID(ctx context.Context, id int64) (AlternativeComponent, error) + GetAnomalyItems(ctx context.Context, warehouseID pgtype.Int8) ([]GetAnomalyItemsRow, error) GetCabinetByID(ctx context.Context, id int64) (Cabinet, error) GetComponentByID(ctx context.Context, id int64) (Component, error) GetComponentCodeByID(ctx context.Context, id int64) (ComponentCode, error) @@ -67,6 +68,7 @@ type Querier interface { GetRoleByID(ctx context.Context, id uuid.UUID) (Role, error) GetRoomByID(ctx context.Context, id int64) (Room, error) GetShelveByID(ctx context.Context, id int64) (Shelf, error) + GetStockAlerts(ctx context.Context) ([]GetStockAlertsRow, error) GetTodayInvoiceCounts(ctx context.Context) ([]GetTodayInvoiceCountsRow, error) GetTotalComponentStats(ctx context.Context, warehouseID pgtype.Int8) (GetTotalComponentStatsRow, error) GetUserByEmail(ctx context.Context, email string) (User, error)