feat: add endpoints and logic for retrieving warehouse space usage and status distribution, including SQL queries, models, and service integration

This commit is contained in:
Tran Anh Tuan
2026-05-14 11:44:39 +07:00
parent cee0186225
commit 84ef7d446e
11 changed files with 612 additions and 0 deletions

View File

@@ -95,3 +95,26 @@ WHERE st.transaction_type = 'export'
GROUP BY c.id, c.name, c.unit, ct.name GROUP BY c.id, c.name, c.unit, ct.name
ORDER BY total_exported DESC ORDER BY total_exported DESC
LIMIT sqlc.arg('limit_count')::int; LIMIT sqlc.arg('limit_count')::int;
-- name: GetStatusDistribution :many
SELECT status, COUNT(*) AS count, COALESCE(SUM(quantity), 0)::bigint AS total_quantity
FROM component_items ci
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 (sqlc.narg('warehouse_id')::bigint IS NULL OR r.warehouse_id = sqlc.narg('warehouse_id')::bigint)
GROUP BY ci.status;
-- name: GetSpaceUsage :many
SELECT w.name AS warehouse, r.name AS room,
COUNT(DISTINCT c.id)::bigint AS total_containers,
COUNT(DISTINCT ci.container_id)::bigint AS used_containers
FROM warehouses w
JOIN rooms r ON r.warehouse_id = w.id
JOIN cabinets cb ON cb.room_id = r.id
JOIN shelves s ON s.cabinet_id = cb.id
JOIN containers c ON c.shelf_id = s.id
LEFT JOIN component_items ci ON ci.container_id = c.id
WHERE (sqlc.narg('warehouse_id')::bigint IS NULL OR w.id = sqlc.narg('warehouse_id')::bigint)
GROUP BY w.name, r.name;

View File

@@ -2247,6 +2247,122 @@ const docTemplate = `{
} }
} }
}, },
"/v1/dashboard/space-usage": {
"get": {
"description": "Retrieve warehouse space usage statistics showing total vs used containers per room",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"dashboard"
],
"summary": "Get space usage",
"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.SpaceUsageItem"
}
}
}
}
]
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/response.ErrorResponse"
}
}
}
}
},
"/v1/dashboard/status-distribution": {
"get": {
"description": "Retrieve component items count grouped by status (normal, damaged, expired, etc.)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"dashboard"
],
"summary": "Get status distribution",
"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.StatusDistributionItem"
}
}
}
}
]
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/response.ErrorResponse"
}
}
}
}
},
"/v1/dashboard/stock-alerts": { "/v1/dashboard/stock-alerts": {
"get": { "get": {
"description": "Retrieve list of components that are low on stock (total_quantity \u003c= min_quantity)", "description": "Retrieve list of components that are low on stock (total_quantity \u003c= min_quantity)",
@@ -4598,6 +4714,37 @@ const docTemplate = `{
} }
} }
}, },
"models.SpaceUsageItem": {
"type": "object",
"properties": {
"room": {
"type": "string"
},
"totalContainers": {
"type": "integer"
},
"usedContainers": {
"type": "integer"
},
"warehouse": {
"type": "string"
}
}
},
"models.StatusDistributionItem": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"status": {
"type": "string"
},
"totalQuantity": {
"type": "integer"
}
}
},
"models.StockAlert": { "models.StockAlert": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -2241,6 +2241,122 @@
} }
} }
}, },
"/v1/dashboard/space-usage": {
"get": {
"description": "Retrieve warehouse space usage statistics showing total vs used containers per room",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"dashboard"
],
"summary": "Get space usage",
"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.SpaceUsageItem"
}
}
}
}
]
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/response.ErrorResponse"
}
}
}
}
},
"/v1/dashboard/status-distribution": {
"get": {
"description": "Retrieve component items count grouped by status (normal, damaged, expired, etc.)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"dashboard"
],
"summary": "Get status distribution",
"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.StatusDistributionItem"
}
}
}
}
]
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/response.ErrorResponse"
}
}
}
}
},
"/v1/dashboard/stock-alerts": { "/v1/dashboard/stock-alerts": {
"get": { "get": {
"description": "Retrieve list of components that are low on stock (total_quantity \u003c= min_quantity)", "description": "Retrieve list of components that are low on stock (total_quantity \u003c= min_quantity)",
@@ -4592,6 +4708,37 @@
} }
} }
}, },
"models.SpaceUsageItem": {
"type": "object",
"properties": {
"room": {
"type": "string"
},
"totalContainers": {
"type": "integer"
},
"usedContainers": {
"type": "integer"
},
"warehouse": {
"type": "string"
}
}
},
"models.StatusDistributionItem": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"status": {
"type": "string"
},
"totalQuantity": {
"type": "integer"
}
}
},
"models.StockAlert": { "models.StockAlert": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -321,6 +321,26 @@ definitions:
updatedAt: updatedAt:
type: string type: string
type: object type: object
models.SpaceUsageItem:
properties:
room:
type: string
totalContainers:
type: integer
usedContainers:
type: integer
warehouse:
type: string
type: object
models.StatusDistributionItem:
properties:
count:
type: integer
status:
type: string
totalQuantity:
type: integer
type: object
models.StockAlert: models.StockAlert:
properties: properties:
componentTypeId: componentTypeId:
@@ -2519,6 +2539,78 @@ paths:
summary: Get anomaly items summary: Get anomaly items
tags: tags:
- dashboard - dashboard
/v1/dashboard/space-usage:
get:
consumes:
- application/json
description: Retrieve warehouse space usage statistics showing total vs used
containers per room
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.SpaceUsageItem'
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 space usage
tags:
- dashboard
/v1/dashboard/status-distribution:
get:
consumes:
- application/json
description: Retrieve component items count grouped by status (normal, damaged,
expired, etc.)
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.StatusDistributionItem'
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 status distribution
tags:
- dashboard
/v1/dashboard/stock-alerts: /v1/dashboard/stock-alerts:
get: get:
consumes: consumes:

View File

@@ -76,3 +76,20 @@ func ToDomainTopExportedComponent(r db.GetTopExportedComponentsRow) models.TopEx
TotalExported: r.TotalExported, TotalExported: r.TotalExported,
} }
} }
func ToDomainStatusDistribution(r db.GetStatusDistributionRow) models.StatusDistributionItem {
return models.StatusDistributionItem{
Status: string(r.Status),
Count: r.Count,
TotalQuantity: r.TotalQuantity,
}
}
func ToDomainSpaceUsage(r db.GetSpaceUsageRow) models.SpaceUsageItem {
return models.SpaceUsageItem{
Warehouse: r.Warehouse,
Room: r.Room,
TotalContainers: r.TotalContainers,
UsedContainers: r.UsedContainers,
}
}

View File

@@ -76,3 +76,16 @@ type TopExportedComponent struct {
ComponentTypeName string `json:"componentTypeName"` ComponentTypeName string `json:"componentTypeName"`
TotalExported int64 `json:"totalExported"` TotalExported int64 `json:"totalExported"`
} }
type StatusDistributionItem struct {
Status string `json:"status"`
Count int64 `json:"count"`
TotalQuantity int64 `json:"totalQuantity"`
}
type SpaceUsageItem struct {
Warehouse string `json:"warehouse"`
Room string `json:"room"`
TotalContainers int64 `json:"totalContainers"`
UsedContainers int64 `json:"usedContainers"`
}

View File

@@ -142,3 +142,27 @@ func GetTopExportedComponents(ctx context.Context, queries *db.Queries, startDat
} }
return items, nil return items, nil
} }
func GetStatusDistribution(ctx context.Context, queries *db.Queries, warehouseID pgtype.Int8) ([]models.StatusDistributionItem, error) {
results, err := queries.GetStatusDistribution(ctx, warehouseID)
if err != nil {
return nil, err
}
items := make([]models.StatusDistributionItem, 0, len(results))
for _, r := range results {
items = append(items, mapper.ToDomainStatusDistribution(r))
}
return items, nil
}
func GetSpaceUsage(ctx context.Context, queries *db.Queries, warehouseID pgtype.Int8) ([]models.SpaceUsageItem, error) {
results, err := queries.GetSpaceUsage(ctx, warehouseID)
if err != nil {
return nil, err
}
items := make([]models.SpaceUsageItem, 0, len(results))
for _, r := range results {
items = append(items, mapper.ToDomainSpaceUsage(r))
}
return items, nil
}

View File

@@ -160,6 +160,8 @@ func NewRouter() *gin.Engine {
dashboard.GET("/anomalies", utils.AsyncHandler(services.DashboardAnomalies)) dashboard.GET("/anomalies", utils.AsyncHandler(services.DashboardAnomalies))
dashboard.GET("/transactions-chart", utils.AsyncHandler(services.DashboardTransactionsChart)) dashboard.GET("/transactions-chart", utils.AsyncHandler(services.DashboardTransactionsChart))
dashboard.GET("/top-components", utils.AsyncHandler(services.DashboardTopComponents)) dashboard.GET("/top-components", utils.AsyncHandler(services.DashboardTopComponents))
dashboard.GET("/status-distribution", utils.AsyncHandler(services.DashboardStatusDistribution))
dashboard.GET("/space-usage", utils.AsyncHandler(services.DashboardSpaceUsage))
} }
} }
} }

View File

@@ -211,3 +211,65 @@ func DashboardTopComponents(c *gin.Context) error {
response.Ok(c, "Success", data) response.Ok(c, "Success", data)
return nil return nil
} }
// @Summary Get status distribution
// @Description Retrieve component items count grouped by status (normal, damaged, expired, etc.)
// @Tags dashboard
// @Accept json
// @Produce json
// @Param warehouse_id query int false "Filter by warehouse ID"
// @Success 200 {object} response.SuccessResponse{data=[]models.StatusDistributionItem}
// @Failure 400 {object} response.ErrorResponse
// @Failure 500 {object} response.ErrorResponse
// @Router /v1/dashboard/status-distribution [get]
func DashboardStatusDistribution(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}
}
data, err := repositories.GetStatusDistribution(c.Request.Context(), global.Queries, warehouseID)
if err != nil {
log.Err(err).Msg("Error when Get Status Distribution")
response.InternalServerError(c, http.StatusInternalServerError, "Failed to get status distribution")
return nil
}
response.Ok(c, "Success", data)
return nil
}
// @Summary Get space usage
// @Description Retrieve warehouse space usage statistics showing total vs used containers per room
// @Tags dashboard
// @Accept json
// @Produce json
// @Param warehouse_id query int false "Filter by warehouse ID"
// @Success 200 {object} response.SuccessResponse{data=[]models.SpaceUsageItem}
// @Failure 400 {object} response.ErrorResponse
// @Failure 500 {object} response.ErrorResponse
// @Router /v1/dashboard/space-usage [get]
func DashboardSpaceUsage(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}
}
data, err := repositories.GetSpaceUsage(c.Request.Context(), global.Queries, warehouseID)
if err != nil {
log.Err(err).Msg("Error when Get Space Usage")
response.InternalServerError(c, http.StatusInternalServerError, "Failed to get space usage")
return nil
}
response.Ok(c, "Success", data)
return nil
}

View File

@@ -153,6 +153,89 @@ func (q *Queries) GetContainerStats(ctx context.Context, warehouseID pgtype.Int8
return i, err return i, err
} }
const getSpaceUsage = `-- name: GetSpaceUsage :many
SELECT w.name AS warehouse, r.name AS room,
COUNT(DISTINCT c.id)::bigint AS total_containers,
COUNT(DISTINCT ci.container_id)::bigint AS used_containers
FROM warehouses w
JOIN rooms r ON r.warehouse_id = w.id
JOIN cabinets cb ON cb.room_id = r.id
JOIN shelves s ON s.cabinet_id = cb.id
JOIN containers c ON c.shelf_id = s.id
LEFT JOIN component_items ci ON ci.container_id = c.id
WHERE ($1::bigint IS NULL OR w.id = $1::bigint)
GROUP BY w.name, r.name
`
type GetSpaceUsageRow struct {
Warehouse string `db:"warehouse" json:"warehouse"`
Room string `db:"room" json:"room"`
TotalContainers int64 `db:"total_containers" json:"totalContainers"`
UsedContainers int64 `db:"used_containers" json:"usedContainers"`
}
func (q *Queries) GetSpaceUsage(ctx context.Context, warehouseID pgtype.Int8) ([]GetSpaceUsageRow, error) {
rows, err := q.db.Query(ctx, getSpaceUsage, warehouseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSpaceUsageRow
for rows.Next() {
var i GetSpaceUsageRow
if err := rows.Scan(
&i.Warehouse,
&i.Room,
&i.TotalContainers,
&i.UsedContainers,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getStatusDistribution = `-- name: GetStatusDistribution :many
SELECT status, COUNT(*) AS count, COALESCE(SUM(quantity), 0)::bigint AS total_quantity
FROM component_items ci
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 ($1::bigint IS NULL OR r.warehouse_id = $1::bigint)
GROUP BY ci.status
`
type GetStatusDistributionRow struct {
Status ComponentItemStatusEnum `db:"status" json:"status"`
Count int64 `db:"count" json:"count"`
TotalQuantity int64 `db:"total_quantity" json:"totalQuantity"`
}
func (q *Queries) GetStatusDistribution(ctx context.Context, warehouseID pgtype.Int8) ([]GetStatusDistributionRow, error) {
rows, err := q.db.Query(ctx, getStatusDistribution, warehouseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetStatusDistributionRow
for rows.Next() {
var i GetStatusDistributionRow
if err := rows.Scan(&i.Status, &i.Count, &i.TotalQuantity); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getStockAlerts = `-- name: GetStockAlerts :many const getStockAlerts = `-- name: GetStockAlerts :many
SELECT c.id, c.name, c.unit, c.total_quantity, c.min_quantity, c.component_type_id, SELECT c.id, c.name, c.unit, c.total_quantity, c.min_quantity, c.component_type_id,
ct.name AS component_type_name ct.name AS component_type_name

View File

@@ -68,6 +68,8 @@ type Querier interface {
GetRoleByID(ctx context.Context, id uuid.UUID) (Role, error) GetRoleByID(ctx context.Context, id uuid.UUID) (Role, error)
GetRoomByID(ctx context.Context, id int64) (Room, error) GetRoomByID(ctx context.Context, id int64) (Room, error)
GetShelveByID(ctx context.Context, id int64) (Shelf, error) GetShelveByID(ctx context.Context, id int64) (Shelf, error)
GetSpaceUsage(ctx context.Context, warehouseID pgtype.Int8) ([]GetSpaceUsageRow, error)
GetStatusDistribution(ctx context.Context, warehouseID pgtype.Int8) ([]GetStatusDistributionRow, error)
GetStockAlerts(ctx context.Context) ([]GetStockAlertsRow, error) GetStockAlerts(ctx context.Context) ([]GetStockAlertsRow, error)
GetTodayInvoiceCounts(ctx context.Context) ([]GetTodayInvoiceCountsRow, error) GetTodayInvoiceCounts(ctx context.Context) ([]GetTodayInvoiceCountsRow, error)
GetTopExportedComponents(ctx context.Context, arg GetTopExportedComponentsParams) ([]GetTopExportedComponentsRow, error) GetTopExportedComponents(ctx context.Context, arg GetTopExportedComponentsParams) ([]GetTopExportedComponentsRow, error)