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

373 lines
14 KiB
Go

package analytics
import (
"context"
"strconv"
"time"
"github.com/gofiber/fiber/v2"
)
// Dashboard is "Главная" — the at-a-glance owner/manager landing page this
// platform didn't have before (staff used to land straight on the Kanban
// board with no company-wide summary anywhere). Unlike the rest of this
// package's endpoints it takes no from/to — it's always "right now", same
// reasoning cash.Handler.Summary's shift-scoped queries have for not taking
// a date range. Gated by the same "analytics" permission as everything else
// here (see main.go), so a master landing on /kanban by default is
// unaffected — this endpoint (and its nav entry) simply doesn't appear for
// that role, matching PROJECT.md's existing "master doesn't see Кассу/
// Аналитику" boundary rather than opening a new one.
type dashboardToday struct {
OrdersCount int `json:"orders_count"`
Revenue string `json:"revenue"`
}
type dashboardMonth struct {
Income string `json:"income"`
Expense string `json:"expense"`
Profit string `json:"profit"`
}
type dashboardFunnelStage struct {
Key string `json:"key"`
Label string `json:"label"`
Color string `json:"color"`
Count int `json:"count"`
Revenue string `json:"revenue"`
}
type dashboardRecentOrder struct {
ID string `json:"id"`
OrderNumber *string `json:"order_number"`
DeviceLabel string `json:"device_label"`
ClientName string `json:"client_name"`
Status string `json:"status"`
StatusLabel string `json:"status_label"`
StatusColor string `json:"status_color"`
CreatedAt time.Time `json:"created_at"`
}
// dashboardStaleOrder mirrors dashboardRecentOrder's shape (same fields the
// frontend already knows how to render as a row) but is sorted oldest-
// updated-first instead of newest-created-first — see fetchDashboardStale.
type dashboardStaleOrder struct {
ID string `json:"id"`
OrderNumber *string `json:"order_number"`
DeviceLabel string `json:"device_label"`
ClientName string `json:"client_name"`
Status string `json:"status"`
StatusLabel string `json:"status_label"`
StatusColor string `json:"status_color"`
UpdatedAt time.Time `json:"updated_at"`
}
type dashboardMasterWorkload struct {
MasterName string `json:"master_name"`
Count int `json:"count"`
}
func (h *Handler) Dashboard(c *fiber.Ctx) error {
ctx := context.Background()
today, err := h.fetchDashboardToday(ctx)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
month, err := h.fetchDashboardMonth(ctx)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
funnel, activeCount, readyCount, err := h.fetchDashboardFunnel(ctx)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
debt, err := h.fetchClientDebtTotal(ctx)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
lowStock, err := h.fetchLowStockCount(ctx)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
recent, err := h.fetchDashboardRecentOrders(ctx)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
stale, err := h.fetchDashboardStaleOrders(ctx)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
pendingDiscounts, err := h.fetchDashboardPendingDiscountCount(ctx)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
workload, err := h.fetchDashboardMasterWorkload(ctx)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
return c.JSON(fiber.Map{
"today": today,
"month": month,
"funnel": funnel,
"active_orders_count": activeCount,
"ready_count": readyCount,
"client_debt_total": debt,
"low_stock_count": lowStock,
"recent_orders": recent,
"stale_orders": stale,
"pending_discount_count": pendingDiscounts,
"master_workload": workload,
})
}
func (h *Handler) fetchDashboardToday(ctx context.Context) (dashboardToday, error) {
var t dashboardToday
err := h.db.QueryRow(ctx,
`SELECT COUNT(*) FROM orders
WHERE deleted_at IS NULL AND created_at >= CURRENT_DATE AND created_at < CURRENT_DATE + INTERVAL '1 day'`,
).Scan(&t.OrdersCount)
if err != nil {
return t, err
}
err = h.db.QueryRow(ctx,
`SELECT COALESCE(SUM(amount), 0)::text FROM cash_transactions
WHERE type = 'income' AND created_at >= CURRENT_DATE AND created_at < CURRENT_DATE + INTERVAL '1 day'`,
).Scan(&t.Revenue)
return t, err
}
func (h *Handler) fetchDashboardMonth(ctx context.Context) (dashboardMonth, error) {
var income, expense, payroll string
err := h.db.QueryRow(ctx,
`SELECT COALESCE(SUM(amount) FILTER (WHERE type = 'income'), 0)::text,
COALESCE(SUM(amount) FILTER (WHERE type = 'expense'), 0)::text,
COALESCE(SUM(amount) FILTER (WHERE type = 'payroll'), 0)::text
FROM cash_transactions
WHERE created_at >= date_trunc('month', CURRENT_DATE)
AND created_at < date_trunc('month', CURRENT_DATE) + INTERVAL '1 month'`,
).Scan(&income, &expense, &payroll)
if err != nil {
return dashboardMonth{}, err
}
profit := parseAmount(income) - parseAmount(expense) - parseAmount(payroll)
return dashboardMonth{Income: income, Expense: expense, Profit: strconv.FormatFloat(profit, 'f', 2, 64)}, nil
}
// fetchDashboardFunnel returns every order_statuses row (owner-configurable,
// see migrations/039_order_statuses.sql) with its live order count and
// revenue, zero-count stages included (LEFT JOIN) so a fresh status the
// owner just added still renders instead of silently vanishing from the
// funnel. activeCount/readyCount are folded in here rather than a second
// query — system_role is already in hand per row.
func (h *Handler) fetchDashboardFunnel(ctx context.Context) ([]dashboardFunnelStage, int, int, error) {
rows, err := h.db.Query(ctx,
`SELECT os.key, os.label, os.color, os.system_role,
COUNT(o.id),
COALESCE(SUM(COALESCE(o.final_price, o.price_estimate, 0)), 0)::text
FROM order_statuses os
LEFT JOIN orders o ON o.status = os.key AND o.deleted_at IS NULL
GROUP BY os.key, os.label, os.color, os.system_role, os.sort_order
ORDER BY os.sort_order`)
if err != nil {
return nil, 0, 0, err
}
defer rows.Close()
out := []dashboardFunnelStage{}
activeCount, readyCount := 0, 0
for rows.Next() {
var s dashboardFunnelStage
var systemRole *string
if err := rows.Scan(&s.Key, &s.Label, &s.Color, &systemRole, &s.Count, &s.Revenue); err != nil {
return nil, 0, 0, err
}
role := ""
if systemRole != nil {
role = *systemRole
}
switch role {
case "ready":
readyCount += s.Count
case "new", "completed", "cancelled":
// excluded from "active" — new hasn't started, the other two are terminal
default:
activeCount += s.Count
}
out = append(out, s)
}
return out, activeCount, readyCount, rows.Err()
}
// fetchClientDebtTotal sums what's still owed across every non-cancelled
// order — GREATEST(...,0) floors an overpaid order at zero rather than
// letting it net negative and understate what other clients owe (a credit
// balance isn't the same thing as negative debt from this tile's point of
// view). Cancelled orders are excluded (nothing owed on a job that never
// happened); completed-but-unpaid orders deliberately still count — the
// client picked up an unpaid device, or hasn't picked it up yet either way.
func (h *Handler) fetchClientDebtTotal(ctx context.Context) (string, error) {
var total string
err := h.db.QueryRow(ctx,
`SELECT COALESCE(SUM(GREATEST(COALESCE(o.final_price, o.price_estimate, 0) - COALESCE(paid.amt, 0), 0)), 0)::text
FROM orders o
LEFT JOIN (
SELECT order_id, SUM(amount) AS amt FROM cash_transactions
WHERE type = 'income' AND order_id IS NOT NULL GROUP BY order_id
) paid ON paid.order_id = o.id
WHERE o.deleted_at IS NULL
AND o.status NOT IN (SELECT key FROM order_statuses WHERE system_role = 'cancelled')`,
).Scan(&total)
return total, err
}
// fetchLowStockCount is the same "стоит остатка на складе" threshold
// analytics.Inventory's fetchLowStock already renders on the Отчёты page
// (COALESCE(SUM(qty_remaining),0) <= min_stock) — kept identical so this
// tile's count and that page's list are never one part off from each other.
func (h *Handler) fetchLowStockCount(ctx context.Context) (int, error) {
var count int
err := h.db.QueryRow(ctx,
`SELECT COUNT(*) FROM (
SELECT p.id FROM parts p
LEFT JOIN stock_batches sb ON sb.part_id = p.id
GROUP BY p.id, p.min_stock
HAVING COALESCE(SUM(sb.qty_remaining), 0) <= p.min_stock
) x`,
).Scan(&count)
return count, err
}
func (h *Handler) fetchDashboardRecentOrders(ctx context.Context) ([]dashboardRecentOrder, error) {
rows, err := h.db.Query(ctx,
`SELECT o.id, o.order_number, o.device_type, o.device_brand, o.device_model,
o.status, COALESCE(os.label, o.status), COALESCE(os.color, '#64748b'),
cl.name, o.created_at
FROM orders o
JOIN clients cl ON cl.id = o.client_id
LEFT JOIN order_statuses os ON os.key = o.status
WHERE o.deleted_at IS NULL
ORDER BY o.created_at DESC
LIMIT 8`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []dashboardRecentOrder{}
for rows.Next() {
var r dashboardRecentOrder
var deviceType string
var deviceBrand, deviceModel *string
if err := rows.Scan(&r.ID, &r.OrderNumber, &deviceType, &deviceBrand, &deviceModel,
&r.Status, &r.StatusLabel, &r.StatusColor, &r.ClientName, &r.CreatedAt); err != nil {
return nil, err
}
r.DeviceLabel = deviceLabelParts(deviceType, deviceBrand, deviceModel)
out = append(out, r)
}
return out, rows.Err()
}
// fetchDashboardStaleOrders surfaces orders nobody has touched in a while —
// same "updated_at, terminal statuses excluded" definition as the frontend's
// own orders/orderAge.js (2-day floor before it's worth flagging at all),
// duplicated server-side rather than shared because that file computes a
// per-row color band for an already-fetched order, not a query filter.
// Oldest-first (least recently touched = most in need of attention), capped
// at 5 — this is an "owner center" heads-up tile, not a full worklist (that's
// still /orders with its own date/status filters).
func (h *Handler) fetchDashboardStaleOrders(ctx context.Context) ([]dashboardStaleOrder, error) {
rows, err := h.db.Query(ctx,
`SELECT o.id, o.order_number, o.device_type, o.device_brand, o.device_model,
o.status, COALESCE(os.label, o.status), COALESCE(os.color, '#64748b'),
cl.name, o.updated_at
FROM orders o
JOIN clients cl ON cl.id = o.client_id
LEFT JOIN order_statuses os ON os.key = o.status
WHERE o.deleted_at IS NULL AND o.status NOT IN ('completed', 'cancelled')
AND o.updated_at < NOW() - INTERVAL '2 days'
ORDER BY o.updated_at ASC
LIMIT 5`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []dashboardStaleOrder{}
for rows.Next() {
var r dashboardStaleOrder
var deviceType string
var deviceBrand, deviceModel *string
if err := rows.Scan(&r.ID, &r.OrderNumber, &deviceType, &deviceBrand, &deviceModel,
&r.Status, &r.StatusLabel, &r.StatusColor, &r.ClientName, &r.UpdatedAt); err != nil {
return nil, err
}
r.DeviceLabel = deviceLabelParts(deviceType, deviceBrand, deviceModel)
out = append(out, r)
}
return out, rows.Err()
}
// fetchDashboardPendingDiscountCount counts orders currently parked awaiting
// a discount-approval decision — see order.Handler.Update's
// discountNeedsApproval and DiscountApproval. Zero whenever the feature is
// off (Настройки → Согласование скидок), same as every other optional-
// feature tile on this page.
func (h *Handler) fetchDashboardPendingDiscountCount(ctx context.Context) (int, error) {
var count int
err := h.db.QueryRow(ctx,
`SELECT COUNT(*) FROM orders WHERE deleted_at IS NULL AND discount_pending_price IS NOT NULL`,
).Scan(&count)
return count, err
}
// fetchDashboardMasterWorkload counts each master's non-terminal orders
// (new/in-progress/ready — anything not completed/cancelled, same
// definition fetchDashboardFunnel's activeCount+readyCount uses) so an
// owner can see load distribution at a glance without opening every
// master's filtered Kanban view individually. Unassigned orders group
// under one bucket rather than being dropped, since "nobody's on this yet"
// is itself the kind of thing this tile exists to surface.
func (h *Handler) fetchDashboardMasterWorkload(ctx context.Context) ([]dashboardMasterWorkload, error) {
rows, err := h.db.Query(ctx,
`SELECT COALESCE(o.assigned_master_name, 'Не назначено'), COUNT(*)
FROM orders o
WHERE o.deleted_at IS NULL AND o.status NOT IN ('completed', 'cancelled')
GROUP BY COALESCE(o.assigned_master_name, 'Не назначено')
ORDER BY COUNT(*) DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []dashboardMasterWorkload{}
for rows.Next() {
var w dashboardMasterWorkload
if err := rows.Scan(&w.MasterName, &w.Count); err != nil {
return nil, err
}
out = append(out, w)
}
return out, rows.Err()
}
func deviceLabelParts(deviceType string, brand, model *string) string {
label := deviceType
extra := ""
if brand != nil && *brand != "" {
extra = *brand
}
if model != nil && *model != "" {
if extra != "" {
extra += " "
}
extra += *model
}
if extra != "" {
label += " · " + extra
}
return label
}