updates on the ai agents and time series prediction and updates on the api to
This commit is contained in:
192
controllers/forecastController.go
Normal file
192
controllers/forecastController.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Demand forecast: stored by the engine, served with a staffing gap.
|
||||
//
|
||||
// ─── Why a gap and not just a number ──────────────────────────────────────
|
||||
//
|
||||
// docs/prediction-plan.md §2.4 is explicit: "Demand prediction with no consumer
|
||||
// is a dashboard nobody opens." A number like "expect 42 pickups in 641 on
|
||||
// Thursday" is not actionable on its own — nobody knows whether 42 is fine.
|
||||
//
|
||||
// So the read endpoint joins the forecast to the riders actually on duty in
|
||||
// that zone and reports the GAP. That is the thing an operator can act on:
|
||||
// "641 expects 42 and has 6 riders at an average 5 stops each — short by 12".
|
||||
//
|
||||
// It stops short of moving anyone. `rebalance_riders` is still review-only
|
||||
// because no endpoint reassigns riders between zones, and inventing one here
|
||||
// would be a product decision disguised as plumbing.
|
||||
|
||||
// POST /api/v1/internal/demand-forecast
|
||||
//
|
||||
// Written by AI_engine's forecast job. Upserts on (zone, forday): re-running
|
||||
// the job replaces that day's number rather than accumulating versions, because
|
||||
// the useful question is "what do we currently expect", and the previous
|
||||
// estimate for a day already past is answered by it having been overwritten
|
||||
// before the day arrived.
|
||||
func UpsertDemandForecast(c *fiber.Ctx) error {
|
||||
type item struct {
|
||||
Zone string `json:"zone"`
|
||||
Forday string `json:"forday"` // YYYY-MM-DD
|
||||
Expectedbookings int `json:"expectedbookings"`
|
||||
Model string `json:"model"`
|
||||
Reason string `json:"reason"`
|
||||
Observations int `json:"observations"`
|
||||
Baselinemae *float64 `json:"baselinemae"`
|
||||
Modelmae *float64 `json:"modelmae"`
|
||||
}
|
||||
var body struct {
|
||||
Forecasts []item `json:"forecasts"`
|
||||
}
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
if len(body.Forecasts) == 0 {
|
||||
return utils.BadRequest(c, "forecasts is required")
|
||||
}
|
||||
|
||||
now := utils.DBNow()
|
||||
rows := make([]models.DemandForecast, 0, len(body.Forecasts))
|
||||
for _, f := range body.Forecasts {
|
||||
if f.Zone == "" || f.Model == "" {
|
||||
continue
|
||||
}
|
||||
day, err := time.Parse("2006-01-02", f.Forday)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if f.Expectedbookings < 0 {
|
||||
// A negative count is a model artefact, not a forecast. The engine
|
||||
// clamps already; this is the second line of defence.
|
||||
f.Expectedbookings = 0
|
||||
}
|
||||
rows = append(rows, models.DemandForecast{
|
||||
Zone: f.Zone,
|
||||
Forday: day,
|
||||
Expectedbookings: f.Expectedbookings,
|
||||
Model: f.Model,
|
||||
Reason: f.Reason,
|
||||
Observations: f.Observations,
|
||||
Baselinemae: f.Baselinemae,
|
||||
Modelmae: f.Modelmae,
|
||||
Generatedat: now,
|
||||
})
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return utils.BadRequest(c, "no usable forecast rows")
|
||||
}
|
||||
|
||||
if err := db.DB.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "zone"}, {Name: "forday"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"expectedbookings", "model", "reason", "observations",
|
||||
"baselinemae", "modelmae", "generatedat",
|
||||
}),
|
||||
}).CreateInBatches(&rows, 200).Error; err != nil {
|
||||
utils.Error("UpsertDemandForecast: write failed", "error", err)
|
||||
return utils.Internal(c, "failed to store the forecast")
|
||||
}
|
||||
|
||||
return utils.OK(c, fiber.Map{"stored": len(rows)})
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/forecast/demand?days=7
|
||||
//
|
||||
// Tomorrow onward, per zone, with the staffing gap. Doormile staff only, like
|
||||
// the rest of the /admin/ai surface it sits beside.
|
||||
func GetDemandForecast(c *fiber.Ctx) error {
|
||||
days, err := strconv.Atoi(c.Query("days", "7"))
|
||||
if err != nil || days < 1 || days > 30 {
|
||||
days = 7
|
||||
}
|
||||
|
||||
// From today, in the database's own day. utils.DBToday rather than
|
||||
// time.Now(): the column holds IST wall-clock digits, so a UTC-derived
|
||||
// boundary would drop today's row for five and a half hours each evening.
|
||||
from := utils.DBToday()
|
||||
to := from.AddDate(0, 0, days)
|
||||
|
||||
type row struct {
|
||||
Zone string `json:"zone"`
|
||||
Forday time.Time `json:"forday"`
|
||||
Expectedbookings int `json:"expectedbookings"`
|
||||
Model string `json:"model"`
|
||||
Reason string `json:"reason"`
|
||||
Observations int `json:"observations"`
|
||||
Baselinemae *float64 `json:"baselinemae"`
|
||||
Modelmae *float64 `json:"modelmae"`
|
||||
Generatedat time.Time `json:"generatedat"`
|
||||
// Ridersonduty is the live count for that zone — the same
|
||||
// availabilitystatus set the batch-assign solver treats as working, so
|
||||
// the gap is measured against the riders that could actually take work.
|
||||
Ridersonduty int `json:"ridersonduty"`
|
||||
// Capacity is riders × the per-rider cap the batch solver uses, so the
|
||||
// gap is in the same unit the assignment path thinks in.
|
||||
Capacity int `json:"capacity"`
|
||||
Gap int `json:"gap"`
|
||||
}
|
||||
var rows []row
|
||||
|
||||
// ORDER BY gap, not f.gap. "gap" is a computed alias in the SELECT list,
|
||||
// not a column of f, and qualifying it with the table alias is an error
|
||||
// Postgres raises at execution time — so this endpoint 500s on every call
|
||||
// rather than failing to compile. Caught by running the query, not by go vet.
|
||||
//
|
||||
// (And the explanation lives here rather than as a -- comment inside the
|
||||
// query: the SQL is a backtick-delimited raw string, so a backtick in a
|
||||
// comment terminates it. That mistake cost a build.)
|
||||
// Riders are joined by the hub's pincode prefix, because a rider belongs to
|
||||
// a hub and a zone IS a pincode prefix. Zones with a forecast and no hub
|
||||
// come back with zero riders rather than being dropped — "we expect 40 and
|
||||
// have nobody" is the single most important row this endpoint can return,
|
||||
// and an inner join would hide it.
|
||||
if err := db.DB.Raw(`
|
||||
SELECT f.zone, f.forday, f.expectedbookings, f.model, f.reason,
|
||||
f.observations, f.baselinemae, f.modelmae, f.generatedat,
|
||||
COALESCE(r.on_duty, 0) AS ridersonduty,
|
||||
COALESCE(r.on_duty, 0) * ? AS capacity,
|
||||
f.expectedbookings - COALESCE(r.on_duty, 0) * ? AS gap
|
||||
FROM demandforecast f
|
||||
LEFT JOIN (
|
||||
SELECT left(h.pincode, 3) AS zone, count(*) AS on_duty
|
||||
FROM milerprofiles mp
|
||||
JOIN hubs h ON h.hubid = mp.hubid
|
||||
WHERE mp.availabilitystatus IN ?
|
||||
GROUP BY 1
|
||||
) r ON r.zone = f.zone
|
||||
WHERE f.forday >= ? AND f.forday < ?
|
||||
ORDER BY f.forday ASC, gap DESC`,
|
||||
defaultBatchAssignCapPerRider, defaultBatchAssignCapPerRider,
|
||||
constants.MilerWorkingStatuses, from, to,
|
||||
).Scan(&rows).Error; err != nil {
|
||||
utils.Error("GetDemandForecast: query failed", "error", err)
|
||||
return utils.Internal(c, "failed to read the forecast")
|
||||
}
|
||||
|
||||
// Said explicitly rather than left for the reader to infer from an empty
|
||||
// array: no forecast and a broken forecast look identical otherwise.
|
||||
note := ""
|
||||
if len(rows) == 0 {
|
||||
note = "No forecast has been generated for this window. The engine's forecast job writes it; check that AI_engine is running and has enough delivery history."
|
||||
}
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
"days": days,
|
||||
"from": from,
|
||||
"capperrider": defaultBatchAssignCapPerRider,
|
||||
"zones": rows,
|
||||
"note": note,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user