updates on the ai agents and time series prediction and updates on the api to
This commit is contained in:
@@ -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")
|
||||
|
||||
87
controllers/aiEngineProxyController.go
Normal file
87
controllers/aiEngineProxyController.go
Normal 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)
|
||||
}
|
||||
287
controllers/aiFindingController.go
Normal file
287
controllers/aiFindingController.go
Normal 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
|
||||
}
|
||||
266
controllers/batchAssignService.go
Normal file
266
controllers/batchAssignService.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user