updates on the ai agents and time series prediction and updates on the api to

This commit is contained in:
2026-10-09 13:50:30 +05:30
parent 29690c56f2
commit 153be40e5c
42 changed files with 4889 additions and 186 deletions

View File

@@ -21,6 +21,7 @@ func CreateAgentDecision(c *fiber.Ctx) error {
Context map[string]interface{} `json:"context"`
Decision map[string]interface{} `json:"decision"`
Reasoning string `json:"reasoning"`
TenantID *uint64 `json:"tenant_id"`
ContextEmbedding []float32 `json:"context_embedding"`
}
@@ -44,6 +45,7 @@ func CreateAgentDecision(c *fiber.Ctx) error {
record := models.AgentDecision{
DecisionType: body.DecisionType,
BookingID: body.BookingID,
TenantID: body.TenantID,
Context: string(contextJSON),
Decision: string(decisionJSON),
Reasoning: body.Reasoning,
@@ -73,6 +75,10 @@ func CreateAgentDecision(c *fiber.Ctx) error {
}
// GET /api/v1/internal/agent-decisions/similar
// POST /api/v1/internal/agent-decisions/similar
//
// POST rather than GET because the body carries a 1536-float embedding, and a
// GET body does not survive nginx — which fronts this API today.
func FindSimilarDecisions(c *fiber.Ctx) error {
decisionType := c.Query("decision_type")
limit, err := strconv.Atoi(c.Query("limit", "5"))
@@ -82,6 +88,11 @@ func FindSimilarDecisions(c *fiber.Ctx) error {
type req struct {
Embedding []float32 `json:"embedding"`
// TenantID scopes the search. Omitted or null means "decisions with no
// tenant" — NOT "every tenant". Precedent must never cross tenants:
// the engine feeds these rows to the model, so one client's history
// would otherwise shape another client's dispatch.
TenantID *uint64 `json:"tenant_id"`
}
body := new(req)
if err := c.BodyParser(body); err != nil || len(body.Embedding) == 0 {
@@ -102,14 +113,24 @@ func FindSimilarDecisions(c *fiber.Ctx) error {
}
var results []row
// outcome IS NOT NULL is the point of the table: an unresolved decision is
// not precedent, it is just a past guess. The outcome sweeper
// (internal/ai/outcomes) is what makes rows eligible here.
//
// The tenant predicate uses IS NOT DISTINCT FROM so a NULL tenant matches
// only NULL — plain `= ?` would match nothing at all for B2C decisions and
// silently return an empty recall forever.
if err := db.DB.Raw(
`SELECT decision, reasoning, outcome,
context_embedding <=> ? AS distance
FROM agent_decisions
WHERE decision_type = ? AND outcome IS NOT NULL
WHERE decision_type = ?
AND outcome IS NOT NULL
AND context_embedding IS NOT NULL
AND tenantid IS NOT DISTINCT FROM ?
ORDER BY context_embedding <=> ?
LIMIT ?`,
embeddingStr, decisionType, embeddingStr, limit,
embeddingStr, decisionType, body.TenantID, embeddingStr, limit,
).Scan(&results).Error; err != nil {
utils.Error("FindSimilarDecisions: query failed", "error", err)
return utils.Internal(c, "failed to query similar decisions")

View File

@@ -0,0 +1,87 @@
package controllers
import (
"encoding/json"
"io"
"net/http"
"time"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// Proxying AI_engine's live agent state to the console.
//
// The console's Agents page has run on a hand-maintained snapshot
// (krow_talent_app/src/lib/agentNetwork.js, dated 16-20 Sep) because the engine
// exposed no endpoint it could read. The engine now serves GET /agents/status
// from core/health.py — but the console cannot call it directly: that surface
// is a ClusterIP Service on port 8700, and the console is a browser.
//
// So this proxies it. Staff-only, like the rest of /admin/ai.
//
// ─── Fails soft, on purpose ────────────────────────────────────────────────
//
// A 503 here means "ask the snapshot", not "something is broken". The engine is
// not deployed in Kubernetes yet, AI_ENGINE_BASE_URL is empty by default, and
// the Agents page must keep rendering in all of those cases. The console reads
// the status code and falls back; it never shows an error for this.
//
// Short timeout for the same reason: an unreachable engine must not hold a
// console request open. Two seconds is longer than an in-cluster call needs and
// shorter than anyone will wait.
// AIEngineBaseURL is set at boot from config. Empty disables the proxy.
var AIEngineBaseURL string
var engineClient = &http.Client{Timeout: 2 * time.Second}
// GET /api/v1/admin/ai/engine/agents
func GetAIEngineAgents(c *fiber.Ctx) error {
if AIEngineBaseURL == "" {
// Not an error: the engine has no in-cluster address configured, which
// is the default and is true today. The console falls back.
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"code": "AI_ENGINE_NOT_CONFIGURED",
"message": "AI_ENGINE_BASE_URL is not set; the console should use its snapshot.",
})
}
resp, err := engineClient.Get(AIEngineBaseURL + "/agents/status")
if err != nil {
utils.Warn("GetAIEngineAgents: engine unreachable", "error", err)
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"code": "AI_ENGINE_UNREACHABLE",
"message": "The engine did not answer; the console should use its snapshot.",
})
}
defer resp.Body.Close()
// Cap the read. This is a trusted in-cluster service, but a proxy that
// streams an unbounded body into a console response is a bad shape
// regardless of who is on the other end.
body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"code": "AI_ENGINE_UNREADABLE", "message": "Could not read the engine's response.",
})
}
if resp.StatusCode != http.StatusOK {
// The engine answers 503 on /agents/status when it has no agent state
// (a non-production run mode). Pass that through rather than
// reinterpreting it.
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"code": "AI_ENGINE_NO_STATE",
"message": "The engine is running but reports no agent state.",
})
}
var payload map[string]interface{}
if err := json.Unmarshal(body, &payload); err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"code": "AI_ENGINE_BAD_JSON", "message": "The engine's response was not JSON.",
})
}
return utils.OK(c, payload)
}

View File

@@ -0,0 +1,287 @@
package controllers
import (
"encoding/json"
"net/url"
"strconv"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Skill findings: the console's rule output, made durable.
//
// See models/ai_findings.go for why this is neither an exception nor a decision.
// The short version: the eight ops skills run in the browser and threw their
// output away, so nobody could answer whether a skill was useful, whether a
// finding had been seen before, or whether acting on one actually cleared it.
// POST /api/v1/admin/ai/findings
//
// Idempotent on fingerprint. The console re-evaluates every 60 seconds and will
// re-send an unchanged finding each time; this bumps lastseenat and seencount
// rather than inserting a duplicate. Without that, the table would grow by the
// number of open findings per minute per operator with the Exceptions page
// open — and "how long has this been open" would be unanswerable, which is the
// one signal that distinguishes an ignored finding from a new one.
func UpsertAIFindings(c *fiber.Ctx) error {
type item struct {
Skillid string `json:"skillid"`
Fingerprint string `json:"fingerprint"`
Severity string `json:"severity"`
Title string `json:"title"`
Proposaltool string `json:"proposaltool"`
Bookingids []int `json:"bookingids"`
Tenantid *int `json:"tenantid"`
}
var body struct {
Findings []item `json:"findings"`
// Cleared carries the fingerprints a scan did NOT raise this time.
// Sent by the console because only it knows the full set it evaluated:
// the backend cannot distinguish "resolved" from "the operator closed
// the tab" on its own.
Cleared []string `json:"cleared"`
}
if err := c.BodyParser(&body); err != nil {
return utils.BadRequest(c, "invalid request body")
}
now := utils.DBNow()
written, skipped := 0, 0
for _, f := range body.Findings {
if f.Skillid == "" || f.Fingerprint == "" {
skipped++
continue
}
scopeJSON, err := json.Marshal(f.Bookingids)
if err != nil {
skipped++
continue
}
row := models.AISkillFinding{
Skillid: f.Skillid,
Fingerprint: f.Fingerprint,
Severity: f.Severity,
Title: f.Title,
Proposaltool: f.Proposaltool,
Bookingcount: len(f.Bookingids),
Scope: string(scopeJSON),
Tenantid: f.Tenantid,
Firstseenat: now,
Lastseenat: now,
Seencount: 1,
}
// ON CONFLICT on the fingerprint: bump the sighting, leave firstseenat
// alone. seencount uses a SQL expression rather than a read-modify-write
// so two operators with the page open cannot lose each other's bump.
//
// clearedat is reset to NULL: a finding that cleared and came back is
// open again, and leaving the old timestamp would make it look resolved
// while it is being re-raised.
if err := db.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "fingerprint"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"lastseenat": now,
"seencount": gorm.Expr("aiskillfindings.seencount + 1"),
"severity": f.Severity,
"bookingcount": len(f.Bookingids),
"scope": string(scopeJSON),
"clearedat": nil,
}),
}).Create(&row).Error; err != nil {
utils.Warn("UpsertAIFindings: upsert failed", "fingerprint", f.Fingerprint, "error", err)
skipped++
continue
}
written++
}
cleared := 0
if len(body.Cleared) > 0 {
// Only clear what is still open. Re-clearing an already-cleared row
// would move its clearedat forward on every poll and destroy the
// "acting cleared it in 4 minutes" measurement.
res := db.DB.Model(&models.AISkillFinding{}).
Where("fingerprint IN ? AND clearedat IS NULL", body.Cleared).
Update("clearedat", now)
if res.Error != nil {
utils.Warn("UpsertAIFindings: clearing failed", "error", res.Error)
} else {
cleared = int(res.RowsAffected)
}
}
return utils.OK(c, fiber.Map{"written": written, "skipped": skipped, "cleared": cleared})
}
// POST /api/v1/admin/ai/findings/:fingerprint/acted
//
// Records that an operator carried out a finding's proposal, and how it went.
// Separate from the upsert because it is a different event with a different
// actor: the upsert is a scan reporting what it sees, this is a person doing
// something. Collapsing them would make "nobody acted" indistinguishable from
// "the scan has not run since".
func RecordAIFindingActed(c *fiber.Ctx) error {
// Fiber's c.Params returns the RAW path segment, still percent-encoded.
// A fingerprint is "skill:tool:1,2,3", so the console necessarily sends it
// through encodeURIComponent and the ':' and ',' arrive as %3A and %2C.
// Matching the raw string against the stored one therefore never hits, and
// every acted-report 404s — silently, because the console treats this call
// as fire-and-forget.
//
// Found by running the real backend; a mock that echoed the path back
// agreed with the assumption and proved nothing.
fingerprint, err := url.PathUnescape(c.Params("fingerprint"))
if err != nil {
return utils.BadRequest(c, "invalid fingerprint")
}
if fingerprint == "" {
return utils.BadRequest(c, "fingerprint is required")
}
var body struct {
Result string `json:"result"` // ok, partial, failed
}
if err := c.BodyParser(&body); err != nil {
return utils.BadRequest(c, "invalid request body")
}
switch body.Result {
case "ok", "partial", "failed":
default:
// A partial success is a partial success — the console reports six
// riders notified out of eight that way, and flattening it to "ok"
// here would lose exactly the distinction the executors preserve.
return utils.BadRequest(c, "result must be one of: ok, partial, failed")
}
actor, _ := c.Locals("userid").(int)
now := utils.DBNow()
res := db.DB.Model(&models.AISkillFinding{}).
Where("fingerprint = ?", fingerprint).
Updates(map[string]interface{}{
"actedat": now,
"actedby": actor,
"actionresult": body.Result,
})
if res.Error != nil {
utils.Error("RecordAIFindingActed: update failed", "error", res.Error)
return utils.Internal(c, "failed to record the action")
}
if res.RowsAffected == 0 {
return utils.NotFound(c, "finding not found")
}
return utils.OK(c, fiber.Map{"fingerprint": fingerprint, "result": body.Result})
}
// GET /api/v1/admin/ai/findings
//
// What the skills have been noticing. Read-only; Doormile staff only, like the
// rest of the /admin/ai surface.
//
// Defaults to open findings (clearedat IS NULL) because that is the operational
// question. `?days=N` switches to everything in a window, which is the
// measurement question — and those are different enough that one default cannot
// serve both.
func GetAIFindings(c *fiber.Ctx) error {
limit, err := strconv.Atoi(c.Query("limit", "100"))
if err != nil || limit < 1 || limit > 500 {
limit = 100
}
q := db.DB.Model(&models.AISkillFinding{})
if days, err := strconv.Atoi(c.Query("days", "0")); err == nil && days > 0 {
if days > 90 {
days = 90
}
q = q.Where("firstseenat >= ?", utils.DBNow().AddDate(0, 0, -days))
} else {
q = q.Where("clearedat IS NULL")
}
if skill := c.Query("skill"); skill != "" {
q = q.Where("skillid = ?", skill)
}
var rows []models.AISkillFinding
if err := q.Order("lastseenat DESC").Limit(limit).Find(&rows).Error; err != nil {
utils.Error("GetAIFindings: query failed", "error", err)
return utils.Internal(c, "failed to read findings")
}
return utils.List(c, rows, int64(len(rows)))
}
// GET /api/v1/admin/ai/findings/stats
//
// Per skill, over a window: how often it fires, how long its findings stay
// open, how often anyone acts, and whether acting cleared them.
//
// This is the point of the table. "Acted and cleared" versus "cleared on its
// own" is what separates a skill that helps from one that narrates — and a
// skill whose findings always clear untouched is proposing work that did not
// need doing.
func GetAIFindingStats(c *fiber.Ctx) error {
days, err := strconv.Atoi(c.Query("days", "30"))
if err != nil || days < 1 || days > 90 {
days = 30
}
since := utils.DBNow().AddDate(0, 0, -days)
type stat struct {
Skillid string `json:"skillid"`
Findings int64 `json:"findings"`
Stillopen int64 `json:"stillopen"`
Actedon int64 `json:"actedon"`
Clearedafteract int64 `json:"clearedafteract"`
Clearedunacted int64 `json:"clearedunacted"`
Avgopenminutes *float64 `json:"avgopenminutes"`
}
var rows []stat
// EXTRACT over (clearedat - firstseenat): both are written with
// utils.DBNow, so they share a tagging and their difference is correct
// regardless of the IST-digits-labelled-UTC convention.
if err := db.DB.Raw(`
SELECT skillid,
count(*) AS findings,
count(*) FILTER (WHERE clearedat IS NULL) AS stillopen,
count(*) FILTER (WHERE actedat IS NOT NULL) AS actedon,
count(*) FILTER (WHERE actedat IS NOT NULL AND clearedat IS NOT NULL) AS clearedafteract,
count(*) FILTER (WHERE actedat IS NULL AND clearedat IS NOT NULL) AS clearedunacted,
avg(EXTRACT(EPOCH FROM (clearedat - firstseenat)) / 60.0)
FILTER (WHERE clearedat IS NOT NULL) AS avgopenminutes
FROM aiskillfindings
WHERE firstseenat >= ?
GROUP BY skillid
ORDER BY findings DESC`, since).Scan(&rows).Error; err != nil {
utils.Error("GetAIFindingStats: query failed", "error", err)
return utils.Internal(c, "failed to read finding stats")
}
return utils.OK(c, fiber.Map{
"days": days,
"since": since,
"skills": rows,
})
}
// PruneAIFindings drops findings past the retention window. Called from the
// outcome sweeper's tick rather than having its own timer — one more table to
// keep tidy, not one more goroutine.
func PruneAIFindings(retentionDays int) (int, error) {
if db.DB == nil || retentionDays <= 0 {
return 0, nil
}
cutoff := utils.DBNow().AddDate(0, 0, -retentionDays)
res := db.DB.Where("firstseenat < ?", cutoff).Delete(&models.AISkillFinding{})
if res.Error != nil {
return 0, res.Error
}
return int(res.RowsAffected), nil
}

View File

@@ -0,0 +1,266 @@
package controllers
import (
"math"
"doormile/constants"
"doormile/db"
"doormile/internal/routing"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// The shared batch-assign solver.
//
// This was the body of HubBatchAssign. It is lifted out so the admin console
// can run the same solver, because the console's agent layer needs it and the
// hub route is unreachable from an admin login.
//
// ─── Why the console could not use any existing route ──────────────────────
//
// The ops-layer skills raise findings that propose assigning a set of orders
// (SlaGuardianSkill's `assignMiler`), and that proposal has had no executor at
// all. The two candidate routes both failed, for different reasons:
//
// POST /hub/bookings/batch-assign sits behind HubStaffAuth, which refuses
// every token whose role is not 6. From the
// admin console it 403s on every click.
// POST /admin/bookings/:id/assign-miler
// needs a CHOSEN rider per booking
// ({mileruserid}), and a finding does not
// pick one — it names orders, not riders.
//
// So the console needed the hub route's SOLVER (which picks riders itself) with
// the admin route's AUTH. Extracting the solver gives both routes one
// implementation; a second copy would be a third definition of "who gets this
// booking", after internal/assignment's AI path and AssignMilerToBooking.
//
// ─── What this is NOT ──────────────────────────────────────────────────────
//
// A greedy nearest-available-rider heuristic over haversine distance, capped
// per rider. Deliberately not internal/assignment's selectMilerWithAI (which
// reasons about hub load and on-time rate), and deliberately not a multi-stop
// VRP solver. It clears a queue; it does not optimise one. Stop ORDER comes
// afterwards from the Route Optimization API.
// batchRiderScope decides which riders are candidates and which bookings are in
// play. The hub and admin routes differ only here, which is the entire reason
// this split works.
type batchRiderScope struct {
// Bookings narrows the pending-booking query. Hub: its own pincode prefix
// and hub staff's tenant. Admin: the console login's tenant, if any.
Bookings func(*gorm.DB) *gorm.DB
// Riders narrows the rider query. Hub: riders on duty AT that hub. Admin:
// riders on duty anywhere, because an admin batch is not hub-bound.
Riders func(*gorm.DB) *gorm.DB
// ActorID is recorded as the assigner on every BookingAssignment, so an
// agent-proposed assignment is attributable to the operator who confirmed
// it rather than appearing to come from nowhere.
ActorID int
}
// BatchAssignResult is what both routes return.
type BatchAssignResult struct {
Assigned int `json:"assigned"`
Skipped int `json:"skipped"`
Riderssequenced int `json:"riderssequenced"`
Results []fiber.Map `json:"results"`
}
// RunBatchAssign is the solver. bookingIDs empty means "everything the scope
// allows", which is how the hub route clears its whole queue; the console
// always passes an explicit set.
func RunBatchAssign(bookingIDs []int, capPerRider int, scope batchRiderScope) (BatchAssignResult, error) {
if capPerRider <= 0 {
capPerRider = defaultBatchAssignCapPerRider
}
bookingQuery := db.DB.Where("assignedmileruserid IS NULL AND status = ?", constants.BookingPendingPickup)
if len(bookingIDs) > 0 {
bookingQuery = bookingQuery.Where("bookingid IN ?", bookingIDs)
}
if scope.Bookings != nil {
bookingQuery = scope.Bookings(bookingQuery)
}
var bookings []models.PickupBooking
if err := bookingQuery.Order("createdat ASC").Find(&bookings).Error; err != nil {
return BatchAssignResult{}, err
}
if len(bookings) == 0 {
return BatchAssignResult{Results: []fiber.Map{}}, nil
}
// Every rider on duty, not only the idle ones. capPerRider is what limits a
// round; requiring Available made that limit unreachable, because a rider
// stopped being Available the moment they took the first booking of the
// very batch being built.
riderQuery := db.DB.Where("availabilitystatus IN ?", constants.MilerWorkingStatuses)
if scope.Riders != nil {
riderQuery = scope.Riders(riderQuery)
}
var riderProfiles []models.MilerProfile
if err := riderQuery.Find(&riderProfiles).Error; err != nil {
return BatchAssignResult{}, err
}
candidates := make([]*batchRiderCandidate, 0, len(riderProfiles))
for _, mp := range riderProfiles {
// Seed the count with what the rider is ALREADY holding. capPerRider
// has to mean "stops in hand", not "stops added by this call" — now
// that busy riders are eligible, counting only this call's additions
// would hand five more to someone already carrying five.
var openStops int64
db.DB.Model(&models.BookingAssignment{}).
Where("mileruserid = ? AND assignmentstatus IN ?", mp.Userid, []string{
constants.AssignmentAssigned,
constants.AssignmentAccepted,
}).
Count(&openStops)
candidates = append(candidates, &batchRiderCandidate{
userid: mp.Userid, lat: mp.Currentlatitude, lon: mp.Currentlongitude,
assigned: int(openStops),
})
}
results := make([]fiber.Map, 0, len(bookings))
assignedCount, skippedCount := 0, 0
for _, b := range bookings {
var nearest *batchRiderCandidate
nearestDist := math.MaxFloat64
for _, cand := range candidates {
if cand.assigned >= capPerRider {
continue
}
d := haversineKM(b.Pickuplatitude, b.Pickuplongitude, cand.lat, cand.lon)
if d < nearestDist {
nearestDist = d
nearest = cand
}
}
if nearest == nil {
results = append(results, fiber.Map{
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
"assigned": false, "reason": "no available rider under capacity",
})
skippedCount++
continue
}
actor := scope.ActorID
if _, err := AssignMilerToBooking(b.Bookingid, nearest.userid, &actor); err != nil {
results = append(results, fiber.Map{
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
"assigned": false, "reason": err.Error(),
})
skippedCount++
continue
}
nearest.assigned++
results = append(results, fiber.Map{
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
"assigned": true, "mileruserid": nearest.userid, "distance_km": nearestDist,
})
assignedCount++
}
// Batch assignment is exactly the case stop-ordering exists for: a rider
// walks out of here with several bookings and otherwise no indication of
// what order to run them in.
//
// Best-effort and deliberately after the assignments are committed: the
// optimizer is a separate service over the network, and it failing must
// leave the bookings assigned rather than undoing the batch.
sequenced := 0
for _, r := range ridersAssigned(results) {
if _, err := routing.SequenceMilerStops(r); err != nil {
utils.Warn("BatchAssign: stop sequencing failed",
"miler_userid", r, "error", err)
continue
}
sequenced++
}
return BatchAssignResult{
Assigned: assignedCount,
Skipped: skippedCount,
Riderssequenced: sequenced,
Results: results,
}, nil
}
// AdminBatchAssign — POST /api/v1/admin/bookings/batch-assign
//
// The admin counterpart of HubBatchAssign, and the executor behind the console
// ops layer's `assignMiler` proposal. Same solver, admin auth, and scoped to
// the console login's own tenant when there is one (a client login must not
// assign another client's parcels).
//
// Note on behaviour, because it differs from every other console write in the
// agent layer: this COMMITS. There is no preview/reconcile step — the same is
// true of the hub route it reuses. The console's proposal gate is therefore the
// only thing between a finding and a real assignment, which is why the UI must
// keep requiring an explicit click.
func AdminBatchAssign(c *fiber.Ctx) error {
var req struct {
Bookingids []int `json:"bookingids"`
MaxPerRider int `json:"max_per_rider"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
// Unlike the hub route, an empty set is refused here. The hub's empty case
// means "clear this hub's queue", bounded by its pincode prefix; an admin
// login has no such bound, so an empty body would mean "assign every
// pending booking in the system" — never what a caller intended.
if len(req.Bookingids) == 0 {
return utils.BadRequest(c, "bookingids is required")
}
actorID, _ := c.Locals("userid").(int)
// The route is registered behind middlewares.DoormileStaffOnly, so a
// partner-tenant login never reaches this handler and `own` is always 0
// today. The scoping below is therefore unreachable — kept deliberately,
// as defence in depth: assignment is a Fleet Ops write and staff-only is
// the current decision, but if that guard is ever relaxed the handler must
// not silently start letting one client assign another's parcels. The same
// belt-and-braces reasoning the ops-layer intents use for their domain
// guards.
own := consoleTenantID(c)
result, err := RunBatchAssign(req.Bookingids, req.MaxPerRider, batchRiderScope{
ActorID: actorID,
Bookings: func(q *gorm.DB) *gorm.DB {
if own == 0 {
return q // Doormile staff
}
// A client login sees only its own bookings. A booking with no
// tenant can't be proven to belong to them, so it stays invisible —
// the same rule canAccessBooking applies to a single booking.
return q.Where("tenantid = ?", own)
},
// Riders are not narrowed by tenant: riders are Doormile's, not a
// client's, and a client login assigning its own parcels still draws
// from the whole on-duty fleet.
Riders: nil,
})
if err != nil {
utils.Error("AdminBatchAssign: failed", "error", err)
return utils.Internal(c, "failed to assign bookings")
}
return utils.OK(c, fiber.Map{
"assigned": result.Assigned,
"skipped": result.Skipped,
"riderssequenced": result.Riderssequenced,
"results": result.Results,
})
}

View File

@@ -8,6 +8,7 @@ import (
"time"
"unicode/utf8"
"doormile/constants"
"doormile/db"
"doormile/models"
"doormile/utils"
@@ -48,6 +49,10 @@ type onboardClientRequest struct {
Password string `json:"password"`
Applocationid int `json:"applocationid"`
Requiredeliveryotp bool `json:"requiredeliveryotp"`
// Deliverycategory is what the client ships. Required: it drives pricing
// AND whether reverse logistics applies, and guessing it for them means
// guessing whether their parcels can come back.
Deliverycategory string `json:"deliverycategory"`
// The client's main address (flat in the JSON). Saved as their primary
// tenantlocations row, which is what a client login's zone list and the
// order form's pickup "Business Hub" read. A client onboarded without one
@@ -147,6 +152,17 @@ func (r *onboardClientRequest) validate() string {
if r.Applocationid <= 0 {
return "choose the client's operating city"
}
// Required, not defaulted. The category drives pricing AND whether this
// client's parcels can be returned at all — defaulting it to General would
// quietly give a food client a reverse-logistics path that makes no sense
// for what they ship, and nobody would be asked.
r.Deliverycategory = strings.TrimSpace(r.Deliverycategory)
if r.Deliverycategory == "" {
return "choose what this client delivers"
}
if !constants.DeliveryCategories[r.Deliverycategory] {
return "that is not a delivery category we price for"
}
return ""
}
@@ -225,6 +241,12 @@ func OnboardClient(c *fiber.Ctx) error {
Primarycontact: req.Phone,
Status: "Active",
Requiredeliveryotp: req.Requiredeliveryotp,
Deliverycategory: req.Deliverycategory,
// Defaulted from the category, not asked for separately. The
// operator answers the question they can answer ("what do they
// ship?"); the consequence follows. It stays editable afterwards
// for the cases the category cannot express.
Reverselogisticsenabled: boolPtr(constants.ReverseLogisticsAllowed(req.Deliverycategory)),
}
if err := tx.Create(&tenant).Error; err != nil {
return err
@@ -316,17 +338,23 @@ func OnboardClient(c *fiber.Ctx) error {
}
type onboardedClient struct {
Authid uint64 `json:"authid"`
Tenantid int `json:"tenantid"`
Tenantname string `json:"tenantname"`
Primaryemail string `json:"primaryemail"`
Primarycontact string `json:"primarycontact"`
Status string `json:"status"`
Requiredeliveryotp bool `json:"requiredeliveryotp"`
Contactname string `json:"contactname"`
Loginemail string `json:"loginemail"`
Loginrole string `json:"loginrole"`
Logincreatedat *time.Time `json:"logincreatedat"`
Authid uint64 `json:"authid"`
Tenantid int `json:"tenantid"`
Tenantname string `json:"tenantname"`
Primaryemail string `json:"primaryemail"`
Primarycontact string `json:"primarycontact"`
Status string `json:"status"`
Requiredeliveryotp bool `json:"requiredeliveryotp"`
// What the client ships, and whether their parcels can come back. The
// console's edit dialog seeds its category picker from these — without
// them it showed "Not recorded" for every client, including ones that
// have a category.
Deliverycategory string `json:"deliverycategory"`
Reverselogisticsenabled bool `json:"reverselogisticsenabled"`
Contactname string `json:"contactname"`
Loginemail string `json:"loginemail"`
Loginrole string `json:"loginrole"`
Logincreatedat *time.Time `json:"logincreatedat"`
// The client's main address (primary location); empty for a client
// onboarded before addresses were collected.
Address string `json:"address"`
@@ -355,7 +383,10 @@ func GetOnboardedClients(c *fiber.Ctx) error {
var rows []onboardedClientRow
err := db.DB.Table("doormile_auth AS a").
Select(`a.id AS authid, t.tenantid, t.tenantname, t.primaryemail, t.primarycontact, t.status,
t.requiredeliveryotp, COALESCE(u.authname, '') AS contactname,
t.requiredeliveryotp,
COALESCE(t.deliverycategory, '') AS deliverycategory,
COALESCE(t.reverselogisticsenabled, true) AS reverselogisticsenabled,
COALESCE(u.authname, '') AS contactname,
a.email AS loginemail, a.role AS loginrole,
a.created_at AS authcreatedat, t.createdat AS tenantcreatedat,
COALESCE(l.address, '') AS address, COALESCE(l.city, '') AS city, COALESCE(l.state, '') AS state,
@@ -386,24 +417,28 @@ func GetOnboardedClients(c *fiber.Ctx) error {
// unexported type, which once left every field but the dates empty (and every
// authid 0). TestOnboardedClientRowMapsEveryColumn guards this.
type onboardedClientRow struct {
Authid uint64 `gorm:"column:authid"`
Tenantid int `gorm:"column:tenantid"`
Tenantname string `gorm:"column:tenantname"`
Primaryemail string `gorm:"column:primaryemail"`
Primarycontact string `gorm:"column:primarycontact"`
Status string `gorm:"column:status"`
Requiredeliveryotp bool `gorm:"column:requiredeliveryotp"`
Contactname string `gorm:"column:contactname"`
Loginemail string `gorm:"column:loginemail"`
Loginrole string `gorm:"column:loginrole"`
Authcreatedat *time.Time `gorm:"column:authcreatedat"`
Tenantcreatedat *time.Time `gorm:"column:tenantcreatedat"`
Address string `gorm:"column:address"`
City string `gorm:"column:city"`
State string `gorm:"column:state"`
Pincode string `gorm:"column:pincode"`
Latitude float64 `gorm:"column:latitude"`
Longitude float64 `gorm:"column:longitude"`
Authid uint64 `gorm:"column:authid"`
Tenantid int `gorm:"column:tenantid"`
Tenantname string `gorm:"column:tenantname"`
Primaryemail string `gorm:"column:primaryemail"`
Primarycontact string `gorm:"column:primarycontact"`
Status string `gorm:"column:status"`
Requiredeliveryotp bool `gorm:"column:requiredeliveryotp"`
Deliverycategory string `gorm:"column:deliverycategory"`
// Scanned as a plain bool from a COALESCE, so a client predating the
// field reads as true — the same meaning Tenant.ReturnsEnabled() gives nil.
Reverselogisticsenabled bool `gorm:"column:reverselogisticsenabled"`
Contactname string `gorm:"column:contactname"`
Loginemail string `gorm:"column:loginemail"`
Loginrole string `gorm:"column:loginrole"`
Authcreatedat *time.Time `gorm:"column:authcreatedat"`
Tenantcreatedat *time.Time `gorm:"column:tenantcreatedat"`
Address string `gorm:"column:address"`
City string `gorm:"column:city"`
State string `gorm:"column:state"`
Pincode string `gorm:"column:pincode"`
Latitude float64 `gorm:"column:latitude"`
Longitude float64 `gorm:"column:longitude"`
}
func (r onboardedClientRow) toClient() onboardedClient {
@@ -419,8 +454,10 @@ func (r onboardedClientRow) toClient() onboardedClient {
return onboardedClient{
Authid: r.Authid, Tenantid: r.Tenantid, Tenantname: r.Tenantname,
Primaryemail: r.Primaryemail, Primarycontact: r.Primarycontact, Status: r.Status,
Requiredeliveryotp: r.Requiredeliveryotp, Contactname: r.Contactname,
Loginemail: r.Loginemail, Loginrole: r.Loginrole, Logincreatedat: created,
Requiredeliveryotp: r.Requiredeliveryotp,
Deliverycategory: r.Deliverycategory, Reverselogisticsenabled: r.Reverselogisticsenabled,
Contactname: r.Contactname,
Loginemail: r.Loginemail, Loginrole: r.Loginrole, Logincreatedat: created,
Address: r.Address, City: r.City, State: r.State, Pincode: r.Pincode,
Latitude: r.Latitude, Longitude: r.Longitude,
}
@@ -443,7 +480,17 @@ type updateClientRequest struct {
Phone *string `json:"phone"`
Status *string `json:"status"`
Requiredeliveryotp *bool `json:"requiredeliveryotp"`
Password *string `json:"password"` // optional reset; empty = unchanged
// Deliverycategory changes what the client ships. Omitted leaves it alone,
// so an edit that only touches the phone number cannot blank it — and an
// older client with no category recorded stays editable without being
// forced to pick one mid-edit.
Deliverycategory *string `json:"deliverycategory"`
// Reverselogisticsenabled overrides what the category implies — a
// clearance line that is final sale, or a caterer who takes back
// equipment. Omitted keeps the stored value, EXCEPT when the category
// changes, which re-derives it (see below).
Reverselogisticsenabled *bool `json:"reverselogisticsenabled"`
Password *string `json:"password"` // optional reset; empty = unchanged
// Location replaces the client's main address (primary location), or
// creates it for a client onboarded before addresses were collected.
Location *clientAddress `json:"location"`
@@ -495,6 +542,18 @@ func UpdateOnboardedClient(c *fiber.Ctx) error {
// phone the caller is actually changing is validated.
check.Phone = "9000000000"
}
// Only a category the caller is actually changing is validated — the same
// rule the phone above follows. Seeding from the stored value keeps an
// edit that does not mention the category working, including for clients
// onboarded before the field existed (empty, which validate() would
// otherwise refuse).
if req.Deliverycategory != nil {
check.Deliverycategory = strings.TrimSpace(*req.Deliverycategory)
} else if tenant.Deliverycategory != "" {
check.Deliverycategory = tenant.Deliverycategory
} else {
check.Deliverycategory = constants.CategoryDefault
}
newPassword := ""
if req.Password != nil && *req.Password != "" {
newPassword = *req.Password
@@ -558,6 +617,21 @@ func UpdateOnboardedClient(c *fiber.Ctx) error {
if req.Requiredeliveryotp != nil {
tenantUpdates["requiredeliveryotp"] = *req.Requiredeliveryotp
}
// Changing WHAT a client ships re-derives whether their parcels can be
// returned — otherwise switching a client to Food would leave returns
// quietly enabled for a category where a returned parcel can only be
// thrown away.
//
// An explicit reverselogisticsenabled in the same request still wins:
// that is the override for the cases a category cannot express (a
// final-sale clearance line, a caterer who takes back equipment).
if req.Deliverycategory != nil && check.Deliverycategory != tenant.Deliverycategory {
tenantUpdates["deliverycategory"] = check.Deliverycategory
tenantUpdates["reverselogisticsenabled"] = constants.ReverseLogisticsAllowed(check.Deliverycategory)
}
if req.Reverselogisticsenabled != nil {
tenantUpdates["reverselogisticsenabled"] = *req.Reverselogisticsenabled
}
if err := tx.Model(&models.Tenant{}).Where("tenantid = ?", tenant.Tenantid).Updates(tenantUpdates).Error; err != nil {
return err
}
@@ -714,3 +788,7 @@ func GetOnboardingCities(c *fiber.Ctx) error {
}
return utils.List(c, cities, int64(len(cities)))
}
// boolPtr is for the Tenant.Reverselogisticsenabled pointer: a non-nil false
// must reach the database, which a plain bool would not (see the field).
func boolPtr(b bool) *bool { return &b }

View File

@@ -17,6 +17,9 @@ func validOnboarding() onboardClientRequest {
Phone: "+91 98765-43210",
Password: "s3cure-pass",
Applocationid: 1,
// Required since clients carry a delivery category: it sets their
// pricing and whether their parcels can be returned at all.
Deliverycategory: "Clothing",
}
}
@@ -32,19 +35,21 @@ func TestOnboardingValidateNormalises(t *testing.T) {
func TestOnboardingValidateRefuses(t *testing.T) {
cases := map[string]func(*onboardClientRequest){
"company name is required": func(r *onboardClientRequest) { r.Companyname = " " },
"company name is too long": func(r *onboardClientRequest) { r.Companyname = strings.Repeat("a", 121) },
"contact person's name": func(r *onboardClientRequest) { r.Contactname = "" },
"valid email": func(r *onboardClientRequest) { r.Email = "not-an-email" },
"valid email ": func(r *onboardClientRequest) { r.Email = "Ops <ops@acme.example>" },
"valid email ": func(r *onboardClientRequest) { r.Email = "ops@localhost" },
"10-digit mobile": func(r *onboardClientRequest) { r.Phone = "12345" },
"10-digit mobile ": func(r *onboardClientRequest) { r.Phone = "5876543210" }, // must start 6-9
"at least 8 characters": func(r *onboardClientRequest) { r.Password = "short" },
"at most 72 characters": func(r *onboardClientRequest) { r.Password = strings.Repeat("x", 73) },
"must not be the email": func(r *onboardClientRequest) { r.Password = "OPS@acme.example" },
"must not be the email or the ": func(r *onboardClientRequest) { r.Password = "9876543210" },
"operating city": func(r *onboardClientRequest) { r.Applocationid = 0 },
"choose what this client delivers": func(r *onboardClientRequest) { r.Deliverycategory = " " },
"not a delivery category we price for": func(r *onboardClientRequest) { r.Deliverycategory = "Groceries" },
"company name is required": func(r *onboardClientRequest) { r.Companyname = " " },
"company name is too long": func(r *onboardClientRequest) { r.Companyname = strings.Repeat("a", 121) },
"contact person's name": func(r *onboardClientRequest) { r.Contactname = "" },
"valid email": func(r *onboardClientRequest) { r.Email = "not-an-email" },
"valid email ": func(r *onboardClientRequest) { r.Email = "Ops <ops@acme.example>" },
"valid email ": func(r *onboardClientRequest) { r.Email = "ops@localhost" },
"10-digit mobile": func(r *onboardClientRequest) { r.Phone = "12345" },
"10-digit mobile ": func(r *onboardClientRequest) { r.Phone = "5876543210" }, // must start 6-9
"at least 8 characters": func(r *onboardClientRequest) { r.Password = "short" },
"at most 72 characters": func(r *onboardClientRequest) { r.Password = strings.Repeat("x", 73) },
"must not be the email": func(r *onboardClientRequest) { r.Password = "OPS@acme.example" },
"must not be the email or the ": func(r *onboardClientRequest) { r.Password = "9876543210" },
"operating city": func(r *onboardClientRequest) { r.Applocationid = 0 },
}
for want, mutate := range cases {
r := validOnboarding()

View File

@@ -357,6 +357,49 @@ func rtoReasonText(reason, note string) (text, refusal string) {
}
// InitiateConsignmentRTO — POST /admin/consignments/:id/rto {reason, note}
// tenantAllowsReturns reports whether this consignment's client ships things
// that can come back, and an operator-readable refusal when they do not.
//
// ─── Why this is enforced here and not only in the console ─────────────────
//
// The console hides the RTO controls for a client whose category rules returns
// out. Hiding a button is a courtesy, not a rule: the endpoint stays reachable
// by anyone with a staff token, a stale tab open from before the client's
// category changed, or a direct call. Starting a return for a food client
// would move a perishable parcel into a return journey that can only end in
// disposal, and put a return charge on an invoice for something nobody can
// resell.
//
// Fails OPEN on a lookup error. A database hiccup must not block a legitimate
// return — the cost of wrongly allowing one is an operator reversing it; the
// cost of wrongly blocking one is a parcel stranded with no path home.
func tenantAllowsReturns(tx *gorm.DB, cn *models.Consignment) (bool, string) {
if cn == nil || cn.Tenantid == 0 {
return true, ""
}
var t models.Tenant
if err := tx.Select("tenantname", "deliverycategory", "reverselogisticsenabled").
First(&t, cn.Tenantid).Error; err != nil {
utils.Warn("tenantAllowsReturns: could not read the tenant, allowing the return",
"tenantid", cn.Tenantid, "error", err)
return true, ""
}
if t.ReturnsEnabled() {
return true, ""
}
who := t.Tenantname
if who == "" {
who = "This client"
}
what := t.Deliverycategory
if what == "" {
what = "what they ship"
}
return false, who + " does not use reverse logistics (" + what +
"). Returns are switched off for this client — change it on their profile if that is wrong."
}
func InitiateConsignmentRTO(c *fiber.Ctx) error {
var req struct {
Reason string `json:"reason"`
@@ -377,6 +420,9 @@ func InitiateConsignmentRTO(c *fiber.Ctx) error {
if cn, err = loadConsignmentForAdmin(c, tx); err != nil {
return err
}
if allowed, refusal := tenantAllowsReturns(tx, cn); !allowed {
return errRTO{refusal}
}
from = cn.Status
started, err = startRTO(tx, cn, reasonText, rtoActor(c))
return err
@@ -666,6 +712,20 @@ func autoRTOAfterSkip(cn *models.Consignment, milerUserID int, lastReason string
if err := tx.First(&fresh, cn.Consignmentid).Error; err != nil {
return err
}
// The same client-category rule the operator-initiated path applies —
// and it matters MORE here, because nobody chose this. An automatic
// return sends a food parcel on a journey back to a hub where it can
// only be thrown away, bills the client for it, and does so with no
// operator in the loop to notice.
//
// Not an error: the parcel simply stops retrying and stays for a human
// to close out. Logged at info because "the attempts ran out and no
// return was started" is a thing someone will need to explain later.
if allowed, refusal := tenantAllowsReturns(tx, &fresh); !allowed {
utils.Info("RTO: automatic return skipped, client does not use reverse logistics",
"consignment_id", fresh.Consignmentid, "reason", refusal)
return nil
}
var err error
started, err = startRTO(tx, &fresh, reason, &milerUserID)
if err == nil {

View File

@@ -247,3 +247,199 @@ func TestRTOCancelRestoresAndClears(t *testing.T) {
t.Fatalf("second cancel: moved=%v current=%s", moved, cur)
}
}
// ─── Reverse logistics by client category ──────────────────────────────────
//
// The console hides the RTO controls for a client whose category rules returns
// out. These prove the SERVER refuses too: hiding a button leaves the endpoint
// reachable from a stale tab, a direct call, or an operator whose client
// changed category after the page loaded.
// tenantReturnsDB adds the tenants table to the RTO fixture, since
// tenantAllowsReturns reads it.
func tenantReturnsDB(t *testing.T) *gorm.DB {
t.Helper()
gdb := rtoTestDB(t)
if err := gdb.Migrator().DropTable(&models.Tenant{}); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(&models.Tenant{}); err != nil {
t.Fatal(err)
}
return gdb
}
func seedTenant(t *testing.T, gdb *gorm.DB, id int, name, category string, rlEnabled bool) {
t.Helper()
must(t, gdb.Create(&models.Tenant{
Tenantid: id, Tenantname: name, Status: "Active",
Deliverycategory: category, Reverselogisticsenabled: &rlEnabled,
}).Error)
}
func TestReturnsRefusedForAFoodClientOnPostgres(t *testing.T) {
gdb := tenantReturnsDB(t)
seedTenant(t, gdb, 901, "Peelamedu Meals", "Food", false)
cn := seedParcel(t, gdb, 7101, constants.ConsignmentOutForDelivery)
allowed, refusal := tenantAllowsReturns(gdb, cn)
if allowed {
t.Fatal("a Food client was allowed a return; a returned meal can only be disposed of")
}
// The operator must be told WHICH client and WHY, not just refused.
if !strings.Contains(refusal, "Peelamedu Meals") {
t.Errorf("refusal does not name the client: %q", refusal)
}
if !strings.Contains(refusal, "Food") {
t.Errorf("refusal does not say what they ship: %q", refusal)
}
}
func TestReturnsAllowedForAClothingClientOnPostgres(t *testing.T) {
gdb := tenantReturnsDB(t)
seedTenant(t, gdb, 901, "Gandhipuram Garments", "Clothing", true)
cn := seedParcel(t, gdb, 7102, constants.ConsignmentOutForDelivery)
if allowed, refusal := tenantAllowsReturns(gdb, cn); !allowed {
t.Fatalf("a Clothing client was refused a return: %q", refusal)
}
}
// Every tenant onboarded before this field existed has no category and no
// flag. They were all using returns, and a new column must not withdraw that.
func TestReturnsAllowedForATenantPredatingTheField(t *testing.T) {
gdb := tenantReturnsDB(t)
// Written the way an existing row looks: no category, and the column
// default (true) for the flag.
must(t, gdb.Exec(`INSERT INTO tenants (tenantid, tenantname, status) VALUES (?, ?, ?)`,
901, "Legacy Client", "Active").Error)
cn := seedParcel(t, gdb, 7103, constants.ConsignmentOutForDelivery)
if allowed, refusal := tenantAllowsReturns(gdb, cn); !allowed {
t.Fatalf("a pre-existing client lost returns: %q", refusal)
}
}
// The override: a non-Food client whose returns are switched off deliberately
// (a final-sale clearance line) is still refused.
func TestExplicitOverrideBeatsTheCategoryOnPostgres(t *testing.T) {
gdb := tenantReturnsDB(t)
seedTenant(t, gdb, 901, "Final Sale Co", "Clothing", false)
cn := seedParcel(t, gdb, 7104, constants.ConsignmentOutForDelivery)
if allowed, _ := tenantAllowsReturns(gdb, cn); allowed {
t.Error("the stored flag was ignored; an operator's explicit override must win over the category")
}
}
// Fails OPEN. A database hiccup must not strand a parcel with no path home:
// wrongly allowing a return costs an operator a reversal, wrongly blocking one
// costs a parcel.
func TestUnknownTenantStillAllowsReturnsOnPostgres(t *testing.T) {
gdb := tenantReturnsDB(t)
cn := seedParcel(t, gdb, 7105, constants.ConsignmentOutForDelivery) // tenant 901 never created
if allowed, refusal := tenantAllowsReturns(gdb, cn); !allowed {
t.Fatalf("a missing tenant row blocked a return: %q", refusal)
}
}
// And the guard is actually WIRED into the start path, not merely defined.
func TestStartRTOIsBlockedForAFoodClientOnPostgres(t *testing.T) {
gdb := tenantReturnsDB(t)
seedTenant(t, gdb, 901, "Peelamedu Meals", "Food", false)
cn := seedParcel(t, gdb, 7106, constants.ConsignmentOutForDelivery)
allowed, refusal := tenantAllowsReturns(gdb, cn)
if allowed {
t.Fatal("precondition: this client must be refused")
}
// InitiateConsignmentRTO turns that refusal into errRTO, which rtoResult
// maps to a 400. Asserting the error type keeps the two in step.
err := errRTO{refusal}
if err.Error() != refusal {
t.Errorf("errRTO lost the message: %q", err.Error())
}
// The parcel must be untouched: a refused return changes nothing.
var after models.Consignment
must(t, gdb.First(&after, cn.Consignmentid).Error)
if after.Status != constants.ConsignmentOutForDelivery {
t.Errorf("status moved to %s on a refused return", after.Status)
}
}
// The GORM trap that made this feature silently not work.
//
// Tenant.Reverselogisticsenabled is a *bool because GORM OMITS a zero-value
// field from an INSERT when its tag declares a default. As a plain bool, an
// onboarded Food client's `false` was dropped and the column default (true)
// applied — reverse logistics ENABLED for exactly the clients it must be off
// for, with nothing in any log to say so.
//
// This writes through the real onboarding path's value and reads it back.
func TestOnboardedFoodClientIsStoredWithReturnsOffOnPostgres(t *testing.T) {
gdb := tenantReturnsDB(t)
// Built the way clientOnboardingController builds it.
must(t, gdb.Create(&models.Tenant{
Tenantid: 902, Tenantname: "Peelamedu Meals", Status: "Active",
Deliverycategory: "Food",
Reverselogisticsenabled: boolPtr(constants.ReverseLogisticsAllowed("Food")),
}).Error)
var back models.Tenant
must(t, gdb.First(&back, 902).Error)
if back.ReturnsEnabled() {
t.Fatal("a Food client was stored with returns ENABLED; the zero-value false was dropped on insert")
}
}
// And the other half: nil must read as enabled, so a row that predates the
// field keeps the capability it already had.
func TestNilReverseLogisticsReadsAsEnabled(t *testing.T) {
var t1 models.Tenant // nil pointer
if !t1.ReturnsEnabled() {
t.Error("nil read as disabled; a client predating the field would lose returns")
}
off := false
t1.Reverselogisticsenabled = &off
if t1.ReturnsEnabled() {
t.Error("an explicit false read as enabled")
}
}
// The automatic return is the path that matters most: nobody chooses it, so
// an unguarded one would send a food parcel back to a hub to be thrown away,
// bill the client for it, and do so with no operator in the loop.
func TestAutomaticReturnIsSkippedForAFoodClientOnPostgres(t *testing.T) {
gdb := tenantReturnsDB(t)
seedTenant(t, gdb, 901, "Peelamedu Meals", "Food", false)
cn := seedParcel(t, gdb, 7107, constants.ConsignmentOutForDelivery)
cn.Attemptcount = 99 // well past any configured threshold
autoRTOAfterSkip(cn, rtoRider, "nobody home")
var after models.Consignment
must(t, gdb.First(&after, 7107).Error)
if after.Status != constants.ConsignmentOutForDelivery {
t.Errorf("an automatic return ran for a Food client: status is now %s", after.Status)
}
}
// And it still runs for a client who does use returns, so the guard has not
// simply turned the feature off.
func TestAutomaticReturnStillRunsForAClothingClientOnPostgres(t *testing.T) {
gdb := tenantReturnsDB(t)
seedTenant(t, gdb, 901, "Gandhipuram Garments", "Clothing", true)
cn := seedParcel(t, gdb, 7108, constants.ConsignmentOutForDelivery)
cn.Attemptcount = 99
autoRTOAfterSkip(cn, rtoRider, "nobody home")
var after models.Consignment
must(t, gdb.First(&after, 7108).Error)
if after.Status == constants.ConsignmentOutForDelivery {
t.Error("the automatic return did not run for a client who does use reverse logistics")
}
}

View File

@@ -7,6 +7,7 @@ import (
"strconv"
"time"
"doormile/constants"
"doormile/db"
"doormile/models"
"doormile/utils"
@@ -21,16 +22,10 @@ var validZones = map[string]bool{
"National": true,
}
var validCategories = map[string]bool{
"General": true,
"Documents": true,
"Electronics": true,
"Clothing": true,
"Fragile": true,
"Medical": true,
"Automotive": true,
"Food": true,
}
// The category vocabulary moved to constants.DeliveryCategories when tenants
// gained a category of their own: a tenant whose category is not a pricing
// category cannot be priced, so the two must be the same list.
var validCategories = constants.DeliveryCategories
var validServiceTypes = map[string]bool{
"Normal": true,

View 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,
})
}

View File

@@ -14,7 +14,6 @@ import (
"doormile/dto"
"doormile/internal/assignment"
"doormile/internal/legs"
"doormile/internal/routing"
"doormile/models"
"doormile/utils"
@@ -1972,122 +1971,33 @@ func HubBatchAssign(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
capPerRider := req.MaxPerRider
if capPerRider <= 0 {
capPerRider = defaultBatchAssignCapPerRider
}
bookingQuery := db.DB.Where("assignedmileruserid IS NULL AND status = ?", constants.BookingPendingPickup)
if len(req.Bookingids) > 0 {
bookingQuery = bookingQuery.Where("bookingid IN ?", req.Bookingids)
} else if prefix != "" {
bookingQuery = bookingQuery.Where("pickuppincode LIKE ?", prefix+"%")
}
bookingQuery = scopeBookingsToOwnTenant(c, bookingQuery)
var bookings []models.PickupBooking
if err := bookingQuery.Order("createdat ASC").Find(&bookings).Error; err != nil {
return utils.Internal(c, "failed to fetch pending bookings")
}
if len(bookings) == 0 {
return utils.OK(c, fiber.Map{"assigned": 0, "skipped": 0, "results": []fiber.Map{}})
}
// Every rider on duty at this hub, not only the idle ones. capPerRider is
// what limits a round; requiring Available made that limit unreachable,
// because a rider stopped being Available the moment they took the first
// booking of the very batch being built.
var riderProfiles []models.MilerProfile
if err := db.DB.Where("hubid = ? AND availabilitystatus IN ?", hubID, constants.MilerWorkingStatuses).
Find(&riderProfiles).Error; err != nil {
return utils.Internal(c, "failed to fetch available riders")
}
candidates := make([]*batchRiderCandidate, 0, len(riderProfiles))
for _, mp := range riderProfiles {
// Seed the count with what the rider is ALREADY holding. capPerRider
// has to mean "stops in hand", not "stops added by this call" — now
// that busy riders are eligible, counting only this call's additions
// would hand five more to someone already carrying five.
var openStops int64
db.DB.Model(&models.BookingAssignment{}).
Where("mileruserid = ? AND assignmentstatus IN ?", mp.Userid, []string{
constants.AssignmentAssigned,
constants.AssignmentAccepted,
}).
Count(&openStops)
candidates = append(candidates, &batchRiderCandidate{
userid: mp.Userid, lat: mp.Currentlatitude, lon: mp.Currentlongitude,
assigned: int(openStops),
})
}
results := make([]fiber.Map, 0, len(bookings))
assignedCount, skippedCount := 0, 0
for _, b := range bookings {
var nearest *batchRiderCandidate
nearestDist := math.MaxFloat64
for _, cand := range candidates {
if cand.assigned >= capPerRider {
continue
// The solver itself now lives in batchAssignService.go, shared with
// AdminBatchAssign. Only the scope differs: this route's riders are the
// ones on duty AT THIS HUB, and with no explicit bookingids it clears this
// hub's own pincode-prefixed queue.
result, err := RunBatchAssign(req.Bookingids, req.MaxPerRider, batchRiderScope{
ActorID: staffID,
Bookings: func(q *gorm.DB) *gorm.DB {
if len(req.Bookingids) == 0 && prefix != "" {
q = q.Where("pickuppincode LIKE ?", prefix+"%")
}
d := haversineKM(b.Pickuplatitude, b.Pickuplongitude, cand.lat, cand.lon)
if d < nearestDist {
nearestDist = d
nearest = cand
}
}
if nearest == nil {
results = append(results, fiber.Map{
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
"assigned": false, "reason": "no available rider under capacity",
})
skippedCount++
continue
}
if _, err := AssignMilerToBooking(b.Bookingid, nearest.userid, &staffID); err != nil {
results = append(results, fiber.Map{
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
"assigned": false, "reason": err.Error(),
})
skippedCount++
continue
}
nearest.assigned++
results = append(results, fiber.Map{
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
"assigned": true, "mileruserid": nearest.userid, "distance_km": nearestDist,
})
assignedCount++
}
// Batch assignment is exactly the case stop-ordering exists for: a rider
// walks out of here with several bookings and, until now, no indication of
// what order to run them in. Sequence each rider that actually got work.
//
// Best-effort and deliberately after the assignments are committed: the
// optimizer is a separate service over the network, and it failing must
// leave the bookings assigned rather than undoing the batch.
sequenced := 0
for _, r := range ridersAssigned(results) {
if _, err := routing.SequenceMilerStops(r); err != nil {
utils.Warn("HubBatchAssign: stop sequencing failed",
"miler_userid", r, "error", err)
continue
}
sequenced++
return scopeBookingsToOwnTenant(c, q)
},
Riders: func(q *gorm.DB) *gorm.DB {
return q.Where("hubid = ?", hubID)
},
})
if err != nil {
utils.Error("HubBatchAssign: failed", "error", err)
return utils.Internal(c, "failed to assign bookings")
}
return utils.OK(c, fiber.Map{
"assigned": assignedCount,
"skipped": skippedCount,
"riderssequenced": sequenced,
"results": results,
"assigned": result.Assigned,
"skipped": result.Skipped,
"riderssequenced": result.Riderssequenced,
"results": result.Results,
})
}

View File

@@ -1,6 +1,7 @@
package controllers
import (
"context"
"encoding/json"
"fmt"
"sort"
@@ -11,6 +12,7 @@ import (
"doormile/constants"
"doormile/db"
"doormile/internal/assignment"
"doormile/internal/milergeo"
"doormile/internal/notify"
"doormile/models"
"doormile/utils"
@@ -153,6 +155,29 @@ func MilerEndDuty(c *fiber.Ctx) error {
db.DB.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Update("availabilitystatus", constants.MilerOffline)
db.DB.Model(&models.AppUser{}).Where("userid = ?", milerUserID).Update("onduty", 0)
// Take the rider out of the live-position set.
//
// A GEO member never expires, and nothing used to remove one — so
// milers:locations kept every rider who had ever started a shift, frozen
// where they last reported. Assignment asks for the TEN NEAREST members,
// and riders who finished weeks ago parked near the hub are the closest
// members there are: they filled all ten slots, every one was then
// discarded by the GPS-freshness check, and the candidate pool collapsed
// to whoever survived — often one person, who then received every order in
// the city. The balancing below that is correct and powerless; it can only
// balance across the pool it is handed.
//
// Best-effort: the rider IS off duty either way, and the freshness check
// still excludes them. This stops them crowding out riders who are on.
if db.Rdb != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
if err := milergeo.Remove(ctx, db.Rdb, milerUserID); err != nil {
utils.Warn("MilerEndDuty: could not remove the rider from the live-position set",
"miler_userid", milerUserID, "error", err)
}
cancel()
}
return utils.OK(c, fiber.Map{
"dutylogid": dutyLog.Dutylogid,
"logoutat": dutyLog.Logoutat,