373 lines
14 KiB
Go
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
|
|
}
|