193 lines
7.3 KiB
Go
193 lines
7.3 KiB
Go
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,
|
||
})
|
||
}
|