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

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()
}