240 lines
7.9 KiB
Go
240 lines
7.9 KiB
Go
package analytics
|
|
|
|
import (
|
|
"context"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
// reorderLookbackDays is how far back "часто используется" looks by
|
|
// default (?reorder_days= overrides) — long enough to smooth over a slow
|
|
// week, short enough that a discontinued repair's old parts don't linger
|
|
// forever as a "reorder this" suggestion.
|
|
const reorderLookbackDays = 90
|
|
|
|
type lowStockPart struct {
|
|
PartID string `json:"part_id"`
|
|
SKU string `json:"sku"`
|
|
Name string `json:"name"`
|
|
CurrentStock int `json:"current_stock"`
|
|
MinStock int `json:"min_stock"`
|
|
}
|
|
|
|
type reorderSuggestion struct {
|
|
PartID string `json:"part_id"`
|
|
SKU string `json:"sku"`
|
|
Name string `json:"name"`
|
|
CurrentStock int `json:"current_stock"`
|
|
MinStock int `json:"min_stock"`
|
|
QtyConsumed int `json:"qty_consumed"`
|
|
SupplierID *string `json:"supplier_id"`
|
|
SupplierName *string `json:"supplier_name"`
|
|
}
|
|
|
|
type slowMovingPart struct {
|
|
PartID string `json:"part_id"`
|
|
SKU string `json:"sku"`
|
|
Name string `json:"name"`
|
|
CurrentStock int `json:"current_stock"`
|
|
LastMovementAt *time.Time `json:"last_movement_at"`
|
|
DaysIdle int `json:"days_idle"`
|
|
}
|
|
|
|
type cartridgeModelCount struct {
|
|
Model string `json:"model"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
type batchStatusCount struct {
|
|
Status string `json:"status"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
// Inventory reports parts at or below their min_stock threshold — a
|
|
// point-in-time stock level, so no date range applies there — plus
|
|
// cartridge model volume and batch throughput within [from, to), plus two
|
|
// supply-planning views keyed off ?reorder_days= (default 90, independent
|
|
// of from/to — "what to reorder" and "what's gone stale" both need a fixed
|
|
// recent lookback, not an arbitrary user-chosen range): reorder_suggestions
|
|
// (parts actually being consumed lately, so staff can restock proactively
|
|
// instead of waiting for min_stock to trip) and slow_moving (parts sitting
|
|
// in stock with no receipt or consumption in that window — candidates to
|
|
// stop reordering or move/discount).
|
|
func (h *Handler) Inventory(c *fiber.Ctx) error {
|
|
from, to := c.Query("from"), c.Query("to")
|
|
ctx := context.Background()
|
|
|
|
lookbackDays := reorderLookbackDays
|
|
if v, err := strconv.Atoi(c.Query("reorder_days")); err == nil && v > 0 {
|
|
lookbackDays = v
|
|
}
|
|
cutoff := time.Now().AddDate(0, 0, -lookbackDays)
|
|
|
|
lowStock, err := h.fetchLowStock(ctx)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
models, err := h.fetchTopCartridgeModels(ctx, from, to)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
batches, err := h.fetchCartridgeBatchesByStatus(ctx, from, to)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
reorder, err := h.fetchReorderSuggestions(ctx, cutoff)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
slowMoving, err := h.fetchSlowMoving(ctx, cutoff)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"low_stock": lowStock,
|
|
"top_cartridge_models": models,
|
|
"batches_by_status": batches,
|
|
"reorder_suggestions": reorder,
|
|
"slow_moving": slowMoving,
|
|
"reorder_lookback_days": lookbackDays,
|
|
})
|
|
}
|
|
|
|
// fetchReorderSuggestions ranks parts by how much was actually consumed
|
|
// since cutoff — the INNER JOIN on cons means a part with zero consumption
|
|
// in the window simply doesn't appear, which is exactly "часто
|
|
// используется" (a part nobody's touched isn't a reorder candidate, no
|
|
// matter how low its stock is — that's slow_moving's job, not this one's).
|
|
func (h *Handler) fetchReorderSuggestions(ctx context.Context, cutoff time.Time) ([]reorderSuggestion, error) {
|
|
rows, err := h.db.Query(ctx,
|
|
`SELECT p.id, p.sku, p.name, COALESCE(SUM(b.qty_remaining), 0), p.min_stock,
|
|
MAX(cons.qty_consumed), p.default_supplier_id, MAX(s.name)
|
|
FROM parts p
|
|
LEFT JOIN stock_batches b ON b.part_id = p.id
|
|
LEFT JOIN suppliers s ON s.id = p.default_supplier_id
|
|
JOIN (
|
|
SELECT part_id, SUM(-qty) AS qty_consumed
|
|
FROM stock_movements
|
|
WHERE type = 'consumption' AND created_at >= $1::timestamptz
|
|
GROUP BY part_id
|
|
) cons ON cons.part_id = p.id
|
|
GROUP BY p.id
|
|
ORDER BY MAX(cons.qty_consumed) DESC
|
|
LIMIT 20`, cutoff)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []reorderSuggestion{}
|
|
for rows.Next() {
|
|
var r reorderSuggestion
|
|
if err := rows.Scan(&r.PartID, &r.SKU, &r.Name, &r.CurrentStock, &r.MinStock, &r.QtyConsumed, &r.SupplierID, &r.SupplierName); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// fetchSlowMoving surfaces parts with stock on hand whose most recent
|
|
// activity (receipt or consumption — stock_movements covers both) is
|
|
// older than cutoff, or that have never moved at all — dead-stock
|
|
// candidates, ordered oldest-idle-first so the worst offenders lead.
|
|
func (h *Handler) fetchSlowMoving(ctx context.Context, cutoff time.Time) ([]slowMovingPart, error) {
|
|
rows, err := h.db.Query(ctx,
|
|
`SELECT p.id, p.sku, p.name, COALESCE(SUM(b.qty_remaining), 0), MAX(sm.created_at)
|
|
FROM parts p
|
|
JOIN stock_batches b ON b.part_id = p.id
|
|
LEFT JOIN stock_movements sm ON sm.part_id = p.id
|
|
GROUP BY p.id
|
|
HAVING COALESCE(SUM(b.qty_remaining), 0) > 0
|
|
AND (MAX(sm.created_at) IS NULL OR MAX(sm.created_at) < $1::timestamptz)
|
|
ORDER BY MAX(sm.created_at) ASC NULLS FIRST
|
|
LIMIT 20`, cutoff)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []slowMovingPart{}
|
|
for rows.Next() {
|
|
var r slowMovingPart
|
|
if err := rows.Scan(&r.PartID, &r.SKU, &r.Name, &r.CurrentStock, &r.LastMovementAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if r.LastMovementAt != nil {
|
|
r.DaysIdle = int(time.Since(*r.LastMovementAt).Hours() / 24)
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (h *Handler) fetchLowStock(ctx context.Context) ([]lowStockPart, error) {
|
|
rows, err := h.db.Query(ctx,
|
|
`SELECT p.id, p.sku, p.name, COALESCE(SUM(sb.qty_remaining), 0) AS current_stock, p.min_stock
|
|
FROM parts p
|
|
LEFT JOIN stock_batches sb ON sb.part_id = p.id
|
|
GROUP BY p.id
|
|
HAVING COALESCE(SUM(sb.qty_remaining), 0) <= p.min_stock
|
|
ORDER BY current_stock ASC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []lowStockPart{}
|
|
for rows.Next() {
|
|
var p lowStockPart
|
|
if err := rows.Scan(&p.PartID, &p.SKU, &p.Name, &p.CurrentStock, &p.MinStock); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (h *Handler) fetchTopCartridgeModels(ctx context.Context, from, to string) ([]cartridgeModelCount, error) {
|
|
rows, err := h.db.Query(ctx,
|
|
`SELECT ci.model, COUNT(*) FROM cartridge_items ci
|
|
JOIN cartridge_batches cb ON cb.id = ci.batch_id
|
|
WHERE ($1 = '' OR cb.created_at >= $1::timestamptz)
|
|
AND ($2 = '' OR cb.created_at < $2::timestamptz)
|
|
GROUP BY ci.model ORDER BY COUNT(*) DESC LIMIT 10`, from, to)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []cartridgeModelCount{}
|
|
for rows.Next() {
|
|
var m cartridgeModelCount
|
|
if err := rows.Scan(&m.Model, &m.Count); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (h *Handler) fetchCartridgeBatchesByStatus(ctx context.Context, from, to string) ([]batchStatusCount, error) {
|
|
rows, err := h.db.Query(ctx,
|
|
`SELECT status, COUNT(*) FROM cartridge_batches
|
|
WHERE ($1 = '' OR created_at >= $1::timestamptz)
|
|
AND ($2 = '' OR created_at < $2::timestamptz)
|
|
GROUP BY status ORDER BY COUNT(*) DESC`, from, to)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []batchStatusCount{}
|
|
for rows.Next() {
|
|
var b batchStatusCount
|
|
if err := rows.Scan(&b.Status, &b.Count); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, b)
|
|
}
|
|
return out, rows.Err()
|
|
}
|