101 lines
3.1 KiB
Go
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()
|
|
}
|