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
ORDER BY total_exported DESC
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": {
"get": {
"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": {
"type": "object",
"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": {
"get": {
"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": {
"type": "object",
"properties": {

View File

@@ -321,6 +321,26 @@ definitions:
updatedAt:
type: string
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:
properties:
componentTypeId:
@@ -2519,6 +2539,78 @@ paths:
summary: Get anomaly items
tags:
- 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:
get:
consumes:

View File

@@ -76,3 +76,20 @@ func ToDomainTopExportedComponent(r db.GetTopExportedComponentsRow) models.TopEx
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"`
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
}
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("/transactions-chart", utils.AsyncHandler(services.DashboardTransactionsChart))
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)
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
}
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
SELECT c.id, c.name, c.unit, c.total_quantity, c.min_quantity, c.component_type_id,
ct.name AS component_type_name

View File

@@ -68,6 +68,8 @@ 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)
GetSpaceUsage(ctx context.Context, warehouseID pgtype.Int8) ([]GetSpaceUsageRow, error)
GetStatusDistribution(ctx context.Context, warehouseID pgtype.Int8) ([]GetStatusDistributionRow, error)
GetStockAlerts(ctx context.Context) ([]GetStockAlertsRow, error)
GetTodayInvoiceCounts(ctx context.Context) ([]GetTodayInvoiceCountsRow, error)
GetTopExportedComponents(ctx context.Context, arg GetTopExportedComponentsParams) ([]GetTopExportedComponentsRow, error)