Merge pull request #15 from anxi0uz/feature/back-15

feat: dashboard aggregates, notifications, websocket route tracking
This commit is contained in:
2026-04-17 17:03:17 +03:00
committed by GitHub
11 changed files with 504 additions and 3 deletions

8
go.mod
View File

@@ -36,6 +36,7 @@ require (
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/gosimple/unidecode v1.0.1 // indirect
github.com/huandu/go-clone v1.7.3 // indirect
github.com/huandu/xstrings v1.4.0 // indirect
@@ -59,17 +60,24 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/richardlehane/mscfb v1.0.6 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
github.com/woodsbury/decimal128 v1.3.0 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/excelize/v2 v2.10.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.41.0 // indirect

16
go.sum
View File

@@ -62,6 +62,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo=
github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
@@ -161,6 +163,10 @@ github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfS
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/samber/slog-chi v1.19.0 h1:fl4qH5Hhk7feHtyp4CxJUt7U1TqjPrZ1uueDW9D+Cps=
@@ -180,10 +186,18 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
@@ -214,6 +228,8 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

68
internal/handler/hub.go Normal file
View File

@@ -0,0 +1,68 @@
package handler
import (
"context"
"sync"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
type Hub struct {
mu sync.RWMutex
connections map[uuid.UUID][]*websocket.Conn
trackers map[uuid.UUID]context.CancelFunc
}
func NewHub() *Hub {
return &Hub{
connections: make(map[uuid.UUID][]*websocket.Conn),
trackers: make(map[uuid.UUID]context.CancelFunc),
}
}
func (h *Hub) Register(orderID uuid.UUID, conn *websocket.Conn) {
h.mu.Lock()
defer h.mu.Unlock()
h.connections[orderID] = append(h.connections[orderID], conn)
}
func (h *Hub) Unregister(orderID uuid.UUID, conn *websocket.Conn) {
h.mu.Lock()
defer h.mu.Unlock()
conns := h.connections[orderID]
for i, c := range conns {
if c == conn {
h.connections[orderID] = append(conns[:i], conns[i+1:]...)
break
}
}
}
func (h *Hub) Broadcast(orderID uuid.UUID, msg any) {
h.mu.RLock()
defer h.mu.RUnlock()
for _, conn := range h.connections[orderID] {
conn.WriteJSON(msg)
}
}
func (h *Hub) StartTracker(orderID uuid.UUID, fn func(ctx context.Context)) {
h.mu.Lock()
defer h.mu.Unlock()
if _, exists := h.trackers[orderID]; exists {
return
}
ctx, cancel := context.WithCancel(context.Background())
h.trackers[orderID] = cancel
go fn(ctx)
}
func (h *Hub) StopTracker(orderID uuid.UUID) {
h.mu.Lock()
defer h.mu.Unlock()
if cancel, exists := h.trackers[orderID]; exists {
cancel()
delete(h.trackers, orderID)
}
}

View File

@@ -1,6 +1,9 @@
package handler
import (
"bufio"
"fmt"
"net"
"net/http"
"strconv"
"time"
@@ -45,6 +48,18 @@ type statusRecorder struct {
status int
}
func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
h, ok := r.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, fmt.Errorf("hijack not supportd")
}
return h.Hijack()
}
func (r *statusRecorder) Unwrap() http.ResponseWriter {
return r.ResponseWriter
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)

View File

@@ -1,14 +1,68 @@
package handler
import (
"log/slog"
"net/http"
"github.com/anxi0uz/logiflow/internal/api"
"github.com/anxi0uz/logiflow/internal/models"
storage "github.com/anxi0uz/logiflow/pkg"
"github.com/huandu/go-sqlbuilder"
openapi_types "github.com/oapi-codegen/runtime/types"
)
func (s *Server) ListNotifications(w http.ResponseWriter, r *http.Request, params api.ListNotificationsParams) {
ctx := r.Context()
claims, ok := ctx.Value("user").(*Claims)
if !ok {
slog.ErrorContext(ctx, "Error while casting claims")
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
notifs, err := storage.GetAll[models.Notification](ctx, "notifications", s.DB, func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.EQ("user_id", claims.ID))
if params.UnreadOnly != nil && *params.UnreadOnly {
sb.Where(sb.EQ("is_read", false))
}
})
if err != nil {
slog.ErrorContext(ctx, "Error while getting notifications", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusOK, notifs, RespSuccess)
}
func (s *Server) MarkNotificationRead(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {
ctx := r.Context()
claims, ok := ctx.Value("user").(*Claims)
if !ok {
slog.ErrorContext(ctx, "Error while casting claims")
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
notification, err := storage.GetOne[models.Notification](ctx, s.DB, "notifications", func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.EQ("id", id))
})
if err != nil {
slog.ErrorContext(ctx, "Error while getting notification", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
if notification.UserID != claims.ID {
s.JSON(w, r, http.StatusForbidden, MsgForbidden, RespError)
return
}
notification.IsRead = true
if err := storage.Update(ctx, "notifications", *notification, s.DB, func(sb *sqlbuilder.UpdateBuilder) {
sb.Where(sb.EQ("id", id))
}); err != nil {
slog.ErrorContext(ctx, "Error while updating notification", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusOK, "Updated", RespSuccess)
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/anxi0uz/logiflow/internal/services"
storage "github.com/anxi0uz/logiflow/pkg"
openapi_types "github.com/oapi-codegen/runtime/types"
"github.com/xuri/excelize/v2"
)
func (s *Server) ListOrders(w http.ResponseWriter, r *http.Request, params api.ListOrdersParams) {
@@ -135,6 +136,9 @@ func (s *Server) UpdateOrderStatus(w http.ResponseWriter, r *http.Request, id op
}
return
}
if req.Status == api.OrderStatusUpdateStatusInTransit {
go s.startRouteTracker(id)
}
s.JSON(w, r, http.StatusOK, order, RespSuccess)
}
@@ -157,7 +161,64 @@ func (s *Server) GetOrdersReport(w http.ResponseWriter, r *http.Request, params
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusOK, orders, RespSuccess)
f := excelize.NewFile()
sheet := "Orders"
f.SetSheetName("Sheet1", sheet)
headers := []string{"ID", "Status", "Origin", "Destination", "Weight", "Volume", "Price", "Created At"}
for i, h := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
f.SetCellValue(sheet, cell, h)
}
for row, o := range orders {
values := []any{
o.ID.String(),
o.Status,
o.OriginAddress,
o.DestinationAddress,
o.WeightKg,
o.VolumeM3,
o.TotalPrice,
o.CreatedAt.Format("2006-01-02 15:04:05"),
}
for col, v := range values {
cell, _ := excelize.CoordinatesToCellName(col+1, row+2)
f.SetCellValue(sheet, cell, v)
}
}
buf, err := f.WriteToBuffer()
if err != nil {
slog.ErrorContext(ctx, "excel write failed", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
w.Header().Set("Content-Disposition", "attachment; filename=orders_report.xlsx")
w.WriteHeader(http.StatusOK)
w.Write(buf.Bytes())
}
func (s *Server) GetDashboard(w http.ResponseWriter, r *http.Request) {}
func (s *Server) GetDashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, ok := ctx.Value("user").(*Claims)
if !ok {
slog.ErrorContext(ctx, "error while casting claims")
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
report, err := s.OrderSerice.GetDashboard(ctx, claims.Role)
if err != nil {
if errors.Is(err, services.ErrForbidden) {
s.JSON(w, r, http.StatusForbidden, MsgForbidden, RespError)
return
}
slog.ErrorContext(ctx, "dashboard failed", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusOK, report, RespSuccess)
}

View File

@@ -1,15 +1,23 @@
package handler
import (
"context"
"log/slog"
"net/http"
"time"
"github.com/anxi0uz/logiflow/internal/models"
storage "github.com/anxi0uz/logiflow/pkg"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/huandu/go-sqlbuilder"
openapi_types "github.com/oapi-codegen/runtime/types"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
func (s *Server) GetRoute(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {
ctx := r.Context()
route, err := storage.GetOne[models.Route](ctx, s.DB, "routes", func(sb *sqlbuilder.SelectBuilder) {
@@ -23,4 +31,81 @@ func (s *Server) GetRoute(w http.ResponseWriter, r *http.Request, id openapi_typ
s.JSON(w, r, http.StatusOK, route, RespSuccess)
}
func (s *Server) RouteWebSocket(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {}
func (s *Server) RouteWebSocket(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {
ctx := r.Context()
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
slog.ErrorContext(ctx, "ws upgrade failed", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
defer conn.Close()
orderID := id
s.Hub.Register(id, conn)
defer s.Hub.Unregister(orderID, conn)
s.Hub.mu.RLock()
_, trackerRunning := s.Hub.trackers[orderID]
s.Hub.mu.RUnlock()
if !trackerRunning {
go s.startRouteTracker(orderID)
}
route, err := storage.GetOne[models.Route](r.Context(), s.DB, "routes", func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.EQ("order_id", orderID))
})
if err == nil {
coords, err := route.ParseCoordinates()
if err == nil && route.CurrentIndex < len(coords) {
conn.WriteJSON(map[string]any{
"current_index": route.CurrentIndex,
"coordinate": coords[route.CurrentIndex],
})
}
}
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
}
func (s *Server) startRouteTracker(orderID uuid.UUID) {
s.Hub.StartTracker(orderID, func(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Second):
route, err := storage.GetOne[models.Route](ctx, s.DB, "routes", func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.EQ("order_id", orderID))
})
if err != nil {
return
}
coords, err := route.ParseCoordinates()
if err != nil || len(coords) == 0 {
return
}
if route.CurrentIndex >= len(coords) {
s.Hub.StopTracker(orderID)
return
}
s.Hub.Broadcast(orderID, map[string]any{
"current_index": route.CurrentIndex,
"coordinate": coords[route.CurrentIndex],
})
route.CurrentIndex++
storage.Update(ctx, "routes", *route, s.DB, func(sb *sqlbuilder.UpdateBuilder) {
sb.Where(sb.EQ("order_id", orderID))
})
}
}
})
}

View File

@@ -55,6 +55,7 @@ type Server struct {
Redis *redis.Client
JwtKey []byte
OrderSerice services.OrderServicer
Hub *Hub
}
func NewServer(db *pgxpool.Pool, redis *redis.Client, cfg *config.Config) *Server {
@@ -65,6 +66,7 @@ func NewServer(db *pgxpool.Pool, redis *redis.Client, cfg *config.Config) *Serve
Config: cfg,
JwtKey: []byte(cfg.JwtOpt.Key),
OrderSerice: services.NewOrderService(db, *cfg),
Hub: NewHub(),
}
}

View File

@@ -0,0 +1,30 @@
package models
import "github.com/google/uuid"
type DashboardRevenue struct {
Total float64 `json:"total"`
ThisMonth float64 `json:"thisMonth"`
}
type DashboardOrderStatus struct {
Total int `json:"total`
Delivered int `json:"delivered"`
InTransit int `json:"inTransit`
Pending int `json:"pending"`
Cancelled int `json:"cancelled"`
}
type DashboardDriverStat struct {
ID uuid.UUID `json:"id"`
FullName string `json:"fullName"`
Status string `json:"status"`
Rating float64 `json:"rating"`
CompletedOrders int `json:"completedOrders"`
}
type DashboardReport struct {
Revenue DashboardRevenue `json:"revenue"`
Orders DashboardOrderStatus `json:"orders"`
Drivers []DashboardDriverStat `json:"drivers"`
}

View File

@@ -31,6 +31,7 @@ type OrderServicer interface {
CancelOrder(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string) error
UpdateOrderStatus(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string, req api.OrderStatusUpdate) (*models.Order, error)
GetOrdersReport(ctx context.Context, role string, params api.GetOrdersReportParams) ([]models.Order, error)
GetDashboard(ctx context.Context, role string) (*models.DashboardReport, error)
}
var ErrForbidden = errors.New("forbidden")
@@ -283,9 +284,20 @@ func (s *OrderService) UpdateOrderStatus(ctx context.Context, id uuid.UUID, user
}
order.DriverID = req.DriverId
order.AssignedAt = &now
driver, _ := storage.GetOne[models.Driver](ctx, s.db, "drivers", func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.EQ("id", order.DriverID))
})
s.createNotification(ctx, driver.UserID, "Новый заказ", "Вам назначен новый заказ")
}
if req.Status == api.OrderStatusUpdateStatusInTransit {
if order.CreatedByID == nil {
return nil, fmt.Errorf("created by id needed")
}
s.createNotification(ctx, *order.CreatedByID, "Заказ в пути", "Ваш заказ передан водителю")
}
if req.Status == api.OrderStatusUpdateStatusDelivered {
order.DeliveredAt = &now
s.createNotification(ctx, *order.CreatedByID, "Заказ доставлен", "Ваш заказ успешно доставлен")
}
if err := storage.Update(ctx, "orders", *order, s.db, func(sb *sqlbuilder.UpdateBuilder) {
sb.Where(sb.EQ("id", id))
@@ -320,3 +332,89 @@ func (s *OrderService) GetOrdersReport(ctx context.Context, role string, params
}
return orders, nil
}
func (s *OrderService) GetDashboard(ctx context.Context, role string) (*models.DashboardReport, error) {
if role != "manager" && role != "admin" {
return nil, ErrForbidden
}
var report models.DashboardReport
g, gctx := errgroup.WithContext(ctx)
// query 1: order counts + revenue
g.Go(func() error {
row := s.db.QueryRow(gctx, `
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'delivered') AS delivered,
COUNT(*) FILTER (WHERE status = 'in_transit') AS in_transit,
COUNT(*) FILTER (WHERE status = 'pending') AS pending,
COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled,
COALESCE(SUM(total_price) FILTER (WHERE status = 'delivered'), 0) AS revenue_total,
COALESCE(SUM(total_price) FILTER (WHERE status = 'delivered'
AND created_at >= date_trunc('month', NOW())), 0) AS revenue_this_month
FROM orders
`)
return row.Scan(
&report.Orders.Total,
&report.Orders.Delivered,
&report.Orders.InTransit,
&report.Orders.Pending,
&report.Orders.Cancelled,
&report.Revenue.Total,
&report.Revenue.ThisMonth,
)
})
// query 2: top drivers by completed orders
g.Go(func() error {
rows, err := s.db.Query(gctx, `
SELECT
d.id,
u.full_name,
d.status,
d.rating,
COUNT(o.id) FILTER (WHERE o.status = 'delivered') AS completed_orders
FROM drivers d
JOIN users u ON u.id = d.user_id
LEFT JOIN orders o ON o.driver_id = d.id
GROUP BY d.id, u.full_name, d.status, d.rating
ORDER BY completed_orders DESC
LIMIT 10
`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var ds models.DashboardDriverStat
if err := rows.Scan(&ds.ID, &ds.FullName, &ds.Status, &ds.Rating, &ds.CompletedOrders); err != nil {
return err
}
report.Drivers = append(report.Drivers, ds)
}
return rows.Err()
})
if err := g.Wait(); err != nil {
return nil, err
}
if report.Drivers == nil {
report.Drivers = []models.DashboardDriverStat{}
}
return &report, nil
}
func (s *OrderService) createNotification(ctx context.Context, userID uuid.UUID, title, body string) {
n := models.Notification{
ID: uuid.New(),
UserID: userID,
Title: title,
Body: &body,
IsRead: false,
CreatedAt: time.Now(),
}
if err := storage.Create(ctx, "notifications", n, s.db); err != nil {
slog.ErrorContext(ctx, "failed to create notification", slog.String("error", err.Error()))
}
}

View File

@@ -25,6 +25,7 @@ type mockOrderService struct {
cancelOrder func(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string) error
updateOrderStatus func(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string, req api.OrderStatusUpdate) (*models.Order, error)
getOrdersReport func(ctx context.Context, role string, params api.GetOrdersReportParams) ([]models.Order, error)
getDashboard func(ctx context.Context, role string) (*models.DashboardReport, error)
}
func (m *mockOrderService) CreateOrder(ctx context.Context, req api.OrderCreate, userID uuid.UUID) (*services.CreateOrderResult, error) {
@@ -45,6 +46,9 @@ func (m *mockOrderService) UpdateOrderStatus(ctx context.Context, id uuid.UUID,
func (m *mockOrderService) GetOrdersReport(ctx context.Context, role string, params api.GetOrdersReportParams) ([]models.Order, error) {
return m.getOrdersReport(ctx, role, params)
}
func (m *mockOrderService) GetDashboard(ctx context.Context, role string) (*models.DashboardReport, error) {
return m.getDashboard(ctx, role)
}
// --- Helpers ---
@@ -429,3 +433,63 @@ func TestGetOrdersReport_ServiceError(t *testing.T) {
t.Errorf("expected 500, got %d", w.Code)
}
}
// --- GetDashboard ---
func TestGetDashboard_Success(t *testing.T) {
svc := &mockOrderService{
getDashboard: func(_ context.Context, _ string) (*models.DashboardReport, error) {
return &models.DashboardReport{
Revenue: models.DashboardRevenue{Total: 10000, ThisMonth: 3000},
Orders: models.DashboardOrderStatus{Total: 5, Delivered: 3, Pending: 2},
Drivers: []models.DashboardDriverStat{},
}, nil
},
}
s := newTestServer(svc)
r := httptest.NewRequest(http.MethodGet, "/reports/dashboard", nil)
r = withClaims(r, uuid.New(), "manager")
w := httptest.NewRecorder()
s.GetDashboard(w, r)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestGetDashboard_Forbidden(t *testing.T) {
svc := &mockOrderService{
getDashboard: func(_ context.Context, _ string) (*models.DashboardReport, error) {
return nil, services.ErrForbidden
},
}
s := newTestServer(svc)
r := httptest.NewRequest(http.MethodGet, "/reports/dashboard", nil)
r = withClaims(r, uuid.New(), "client")
w := httptest.NewRecorder()
s.GetDashboard(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", w.Code)
}
}
func TestGetDashboard_ServiceError(t *testing.T) {
svc := &mockOrderService{
getDashboard: func(_ context.Context, _ string) (*models.DashboardReport, error) {
return nil, errors.New("db error")
},
}
s := newTestServer(svc)
r := httptest.NewRequest(http.MethodGet, "/reports/dashboard", nil)
r = withClaims(r, uuid.New(), "manager")
w := httptest.NewRecorder()
s.GetDashboard(w, r)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
}
}