From 96bc22942bbcb0471da75e0c2dae90c32c4d5d0f Mon Sep 17 00:00:00 2001 From: Tran Anh Tuan Date: Thu, 14 May 2026 10:41:25 +0700 Subject: [PATCH] feat: add endpoint and logic for retrieving transaction chart data, including SQL queries, models, and service integration --- db/queries/dashboard.sql | 14 +++ docs/swagger/docs.go | 87 +++++++++++++++++++ docs/swagger/swagger.json | 87 +++++++++++++++++++ docs/swagger/swagger.yaml | 55 ++++++++++++ internal/mapper/dashboard_mapper.go | 8 ++ internal/models/dashboard_model.go | 16 ++++ internal/repositories/dashboard_repository.go | 42 +++++++++ internal/routers/router.go | 1 + internal/services/dashboard_service.go | 55 ++++++++++++ sqlc_gen/dashboard.sql.go | 47 ++++++++++ sqlc_gen/querier.go | 1 + 11 files changed, 413 insertions(+) diff --git a/db/queries/dashboard.sql b/db/queries/dashboard.sql index cf50736..89e004b 100644 --- a/db/queries/dashboard.sql +++ b/db/queries/dashboard.sql @@ -63,3 +63,17 @@ 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; + +-- name: GetTransactionChartData :many +SELECT DATE(st.created_at) AS date, st.transaction_type, COALESCE(SUM(st.quantity), 0)::bigint AS total_quantity +FROM stock_transactions st +JOIN containers con ON st.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 st.transaction_type IN ('import', 'export') + AND st.created_at >= sqlc.arg('start_date')::timestamptz + AND st.created_at < sqlc.arg('end_date')::timestamptz + AND (sqlc.narg('warehouse_id')::bigint IS NULL OR r.warehouse_id = sqlc.narg('warehouse_id')::bigint) +GROUP BY DATE(st.created_at), st.transaction_type +ORDER BY DATE(st.created_at) ASC; diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index fea1249..b14ec16 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -2340,6 +2340,68 @@ const docTemplate = `{ } } }, + "/v1/dashboard/transactions-chart": { + "get": { + "description": "Retrieve import/export transaction quantities grouped by date for chart display", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "Get transactions chart data", + "parameters": [ + { + "type": "string", + "default": "7d", + "description": "Time period: today, 7d, 30d, this_month", + "name": "period", + "in": "query" + }, + { + "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": { + "$ref": "#/definitions/models.TransactionChartData" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + } + } + } + }, "/v1/invoice-config-items": { "get": { "description": "Retrieve a list of all invoice config items", @@ -4512,6 +4574,31 @@ const docTemplate = `{ } } }, + "models.TransactionChartData": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.TransactionChartItem" + } + } + } + }, + "models.TransactionChartItem": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "exportQuantity": { + "type": "integer" + }, + "importQuantity": { + "type": "integer" + } + } + }, "models.Warehouse": { "type": "object", "properties": { diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 9e53ed9..c886f4c 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -2334,6 +2334,68 @@ } } }, + "/v1/dashboard/transactions-chart": { + "get": { + "description": "Retrieve import/export transaction quantities grouped by date for chart display", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard" + ], + "summary": "Get transactions chart data", + "parameters": [ + { + "type": "string", + "default": "7d", + "description": "Time period: today, 7d, 30d, this_month", + "name": "period", + "in": "query" + }, + { + "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": { + "$ref": "#/definitions/models.TransactionChartData" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + } + } + } + }, "/v1/invoice-config-items": { "get": { "description": "Retrieve a list of all invoice config items", @@ -4506,6 +4568,31 @@ } } }, + "models.TransactionChartData": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.TransactionChartItem" + } + } + } + }, + "models.TransactionChartItem": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "exportQuantity": { + "type": "integer" + }, + "importQuantity": { + "type": "integer" + } + } + }, "models.Warehouse": { "type": "object", "properties": { diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 2b57e00..0a422c3 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -352,6 +352,22 @@ definitions: totalTypes: type: integer type: object + models.TransactionChartData: + properties: + items: + items: + $ref: '#/definitions/models.TransactionChartItem' + type: array + type: object + models.TransactionChartItem: + properties: + date: + type: string + exportQuantity: + type: integer + importQuantity: + type: integer + type: object models.Warehouse: properties: address: @@ -2546,6 +2562,45 @@ paths: summary: Get dashboard summary tags: - dashboard + /v1/dashboard/transactions-chart: + get: + consumes: + - application/json + description: Retrieve import/export transaction quantities grouped by date for + chart display + parameters: + - default: 7d + description: 'Time period: today, 7d, 30d, this_month' + in: query + name: period + type: string + - 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: + $ref: '#/definitions/models.TransactionChartData' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.ErrorResponse' + summary: Get transactions chart data + tags: + - dashboard /v1/invoice-config-items: get: consumes: diff --git a/internal/mapper/dashboard_mapper.go b/internal/mapper/dashboard_mapper.go index 2b3307f..f7997a2 100644 --- a/internal/mapper/dashboard_mapper.go +++ b/internal/mapper/dashboard_mapper.go @@ -58,3 +58,11 @@ func ToDomainAnomalyItem(r db.GetAnomalyItemsRow) models.AnomalyItem { ComponentUnit: r.ComponentUnit, } } + +func ToDomainTransactionChartRow(r db.GetTransactionChartDataRow) models.TransactionChartRow { + return models.TransactionChartRow{ + Date: r.Date.Time, + TransactionType: string(r.TransactionType), + TotalQuantity: r.TotalQuantity, + } +} diff --git a/internal/models/dashboard_model.go b/internal/models/dashboard_model.go index e0ed116..08331c4 100644 --- a/internal/models/dashboard_model.go +++ b/internal/models/dashboard_model.go @@ -52,3 +52,19 @@ type AnomalyItem struct { ComponentName string `json:"componentName"` ComponentUnit string `json:"componentUnit"` } + +type TransactionChartRow struct { + Date time.Time + TransactionType string + TotalQuantity int64 +} + +type TransactionChartItem struct { + Date string `json:"date"` + ImportQuantity int64 `json:"importQuantity"` + ExportQuantity int64 `json:"exportQuantity"` +} + +type TransactionChartData struct { + Items []TransactionChartItem `json:"items"` +} diff --git a/internal/repositories/dashboard_repository.go b/internal/repositories/dashboard_repository.go index dede458..e2fc3f6 100644 --- a/internal/repositories/dashboard_repository.go +++ b/internal/repositories/dashboard_repository.go @@ -2,6 +2,8 @@ package repositories import ( "context" + "sort" + "time" "wm-backend/internal/mapper" "wm-backend/internal/models" db "wm-backend/sqlc_gen" @@ -83,3 +85,43 @@ func GetAnomalyItems(ctx context.Context, queries *db.Queries, warehouseID pgtyp } return items, nil } + +func GetTransactionChartData(ctx context.Context, queries *db.Queries, startDate, endDate time.Time, warehouseID pgtype.Int8) (models.TransactionChartData, error) { + results, err := queries.GetTransactionChartData(ctx, db.GetTransactionChartDataParams{ + StartDate: startDate, + EndDate: endDate, + WarehouseID: warehouseID, + }) + if err != nil { + return models.TransactionChartData{}, err + } + + dateMap := make(map[string]*models.TransactionChartItem) + for _, r := range results { + row := mapper.ToDomainTransactionChartRow(r) + dateStr := row.Date.Format("2006-01-02") + if _, exists := dateMap[dateStr]; !exists { + dateMap[dateStr] = &models.TransactionChartItem{ + Date: dateStr, + ImportQuantity: 0, + ExportQuantity: 0, + } + } + switch row.TransactionType { + case "import": + dateMap[dateStr].ImportQuantity = row.TotalQuantity + case "export": + dateMap[dateStr].ExportQuantity = row.TotalQuantity + } + } + + items := make([]models.TransactionChartItem, 0, len(dateMap)) + for _, item := range dateMap { + items = append(items, *item) + } + sort.Slice(items, func(i, j int) bool { + return items[i].Date < items[j].Date + }) + + return models.TransactionChartData{Items: items}, nil +} diff --git a/internal/routers/router.go b/internal/routers/router.go index 8b3bc7b..154b757 100644 --- a/internal/routers/router.go +++ b/internal/routers/router.go @@ -158,6 +158,7 @@ func NewRouter() *gin.Engine { dashboard.GET("/summary", utils.AsyncHandler(services.DashboardSummary)) dashboard.GET("/stock-alerts", utils.AsyncHandler(services.DashboardStockAlerts)) dashboard.GET("/anomalies", utils.AsyncHandler(services.DashboardAnomalies)) + dashboard.GET("/transactions-chart", utils.AsyncHandler(services.DashboardTransactionsChart)) } } } diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go index 3de619f..6ac3947 100644 --- a/internal/services/dashboard_service.go +++ b/internal/services/dashboard_service.go @@ -3,6 +3,7 @@ package services import ( "net/http" "strconv" + "time" "wm-backend/global" "wm-backend/internal/repositories" "wm-backend/response" @@ -91,3 +92,57 @@ func DashboardAnomalies(c *gin.Context) error { response.Ok(c, "Success", anomalies) return nil } + +// @Summary Get transactions chart data +// @Description Retrieve import/export transaction quantities grouped by date for chart display +// @Tags dashboard +// @Accept json +// @Produce json +// @Param period query string false "Time period: today, 7d, 30d, this_month" default(7d) +// @Param warehouse_id query int false "Filter by warehouse ID" +// @Success 200 {object} response.SuccessResponse{data=models.TransactionChartData} +// @Failure 400 {object} response.ErrorResponse +// @Failure 500 {object} response.ErrorResponse +// @Router /v1/dashboard/transactions-chart [get] +func DashboardTransactionsChart(c *gin.Context) error { + period := c.DefaultQuery("period", "7d") + + now := time.Now() + var startDate, endDate time.Time + switch period { + case "today": + startDate = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + endDate = startDate.AddDate(0, 0, 1) + case "7d": + startDate = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -6) + endDate = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, 1) + case "30d": + startDate = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -29) + endDate = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, 1) + case "this_month": + startDate = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + endDate = startDate.AddDate(0, 1, 0) + default: + response.BadRequestError(c, http.StatusBadRequest, "Invalid period. Use: today, 7d, 30d, this_month") + return nil + } + + 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.GetTransactionChartData(c.Request.Context(), global.Queries, startDate, endDate, warehouseID) + if err != nil { + log.Err(err).Msg("Error when Get Transaction Chart Data") + response.InternalServerError(c, http.StatusInternalServerError, "Failed to get transaction chart data") + return nil + } + response.Ok(c, "Success", data) + return nil +} diff --git a/sqlc_gen/dashboard.sql.go b/sqlc_gen/dashboard.sql.go index cb42caa..d5a06e2 100644 --- a/sqlc_gen/dashboard.sql.go +++ b/sqlc_gen/dashboard.sql.go @@ -253,3 +253,50 @@ func (q *Queries) GetTotalComponentStats(ctx context.Context, warehouseID pgtype err := row.Scan(&i.TotalTypes, &i.TotalQuantity) return i, err } + +const getTransactionChartData = `-- name: GetTransactionChartData :many +SELECT DATE(st.created_at) AS date, st.transaction_type, COALESCE(SUM(st.quantity), 0)::bigint AS total_quantity +FROM stock_transactions st +JOIN containers con ON st.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 st.transaction_type IN ('import', 'export') + AND st.created_at >= $1::timestamptz + AND st.created_at < $2::timestamptz + AND ($3::bigint IS NULL OR r.warehouse_id = $3::bigint) +GROUP BY DATE(st.created_at), st.transaction_type +ORDER BY DATE(st.created_at) ASC +` + +type GetTransactionChartDataParams struct { + StartDate time.Time `db:"start_date" json:"startDate"` + EndDate time.Time `db:"end_date" json:"endDate"` + WarehouseID pgtype.Int8 `db:"warehouse_id" json:"warehouseId"` +} + +type GetTransactionChartDataRow struct { + Date pgtype.Date `db:"date" json:"date"` + TransactionType TransactionTypeEnum `db:"transaction_type" json:"transactionType"` + TotalQuantity int64 `db:"total_quantity" json:"totalQuantity"` +} + +func (q *Queries) GetTransactionChartData(ctx context.Context, arg GetTransactionChartDataParams) ([]GetTransactionChartDataRow, error) { + rows, err := q.db.Query(ctx, getTransactionChartData, arg.StartDate, arg.EndDate, arg.WarehouseID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTransactionChartDataRow + for rows.Next() { + var i GetTransactionChartDataRow + if err := rows.Scan(&i.Date, &i.TransactionType, &i.TotalQuantity); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/sqlc_gen/querier.go b/sqlc_gen/querier.go index 592c314..599420f 100644 --- a/sqlc_gen/querier.go +++ b/sqlc_gen/querier.go @@ -71,6 +71,7 @@ type Querier interface { GetStockAlerts(ctx context.Context) ([]GetStockAlertsRow, error) GetTodayInvoiceCounts(ctx context.Context) ([]GetTodayInvoiceCountsRow, error) GetTotalComponentStats(ctx context.Context, warehouseID pgtype.Int8) (GetTotalComponentStatsRow, error) + GetTransactionChartData(ctx context.Context, arg GetTransactionChartDataParams) ([]GetTransactionChartDataRow, error) GetUserByEmail(ctx context.Context, email string) (User, error) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) GetUserByUsername(ctx context.Context, username string) (User, error)