Files
aura-crm/production/backend/internal/analytics/revenue.go
T

101 lines
3.1 KiB
Go

package analytics
import (
"context"
"github.com/gofiber/fiber/v2"
)
var validIntervals = map[string]bool{"day": true, "week": true, "month": true}
type revenueBucket struct {
Bucket string `json:"bucket"`
Income string `json:"income"`
Expense string `json:"expense"`
Payroll string `json:"payroll"`
}
type orderVolumeBucket struct {
Bucket string `json:"bucket"`
Count int `json:"count"`
}
// Revenue returns income/expense/payroll totals and order-creation volume,
// bucketed by the same interval, over [from, to). The roadmap's "load"
// (загрузка) meant business-activity volume, not server load — order count
// is the proxy for that, paired with revenue so both read off the same
// timeline.
func (h *Handler) Revenue(c *fiber.Ctx) error {
interval := c.Query("interval", "day")
if !validIntervals[interval] {
return c.Status(400).JSON(fiber.Map{"error": "interval must be one of: day, week, month"})
}
from, to := c.Query("from"), c.Query("to")
ctx := context.Background()
revenue, err := h.fetchRevenue(ctx, interval, from, to)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
orders, err := h.fetchOrderVolume(ctx, interval, from, to)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
return c.JSON(fiber.Map{"revenue": revenue, "orders_created": orders})
}
func (h *Handler) fetchRevenue(ctx context.Context, interval, from, to string) ([]revenueBucket, error) {
rows, err := h.db.Query(ctx,
`SELECT date_trunc($1, created_at)::text AS bucket,
COALESCE(SUM(amount) FILTER (WHERE type = 'income'), 0)::text AS income,
COALESCE(SUM(amount) FILTER (WHERE type = 'expense'), 0)::text AS expense,
COALESCE(SUM(amount) FILTER (WHERE type = 'payroll'), 0)::text AS payroll
FROM cash_transactions
WHERE ($2 = '' OR created_at >= $2::timestamptz)
AND ($3 = '' OR created_at < $3::timestamptz)
GROUP BY bucket ORDER BY bucket`,
interval, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
out := []revenueBucket{}
for rows.Next() {
var b revenueBucket
if err := rows.Scan(&b.Bucket, &b.Income, &b.Expense, &b.Payroll); err != nil {
return nil, err
}
out = append(out, b)
}
// Malformed from/to fails the ::timestamptz cast only when a row is
// actually scanned — an empty result set would otherwise hide that
// error (see cash.list's identical gotcha, fixed the same way).
return out, rows.Err()
}
func (h *Handler) fetchOrderVolume(ctx context.Context, interval, from, to string) ([]orderVolumeBucket, error) {
rows, err := h.db.Query(ctx,
`SELECT date_trunc($1, created_at)::text AS bucket, COUNT(*)
FROM orders
WHERE ($2 = '' OR created_at >= $2::timestamptz)
AND ($3 = '' OR created_at < $3::timestamptz)
GROUP BY bucket ORDER BY bucket`,
interval, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
out := []orderVolumeBucket{}
for rows.Next() {
var b orderVolumeBucket
if err := rows.Scan(&b.Bucket, &b.Count); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}