hub apis
This commit is contained in:
3
.env
3
.env
@@ -16,3 +16,6 @@ REDIS_HOST=31.97.228.132
|
|||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
REDIS_USER=admin
|
REDIS_USER=admin
|
||||||
REDIS_PASSWORD=Package@321#
|
REDIS_PASSWORD=Package@321#
|
||||||
|
|
||||||
|
# AI Decision Engine
|
||||||
|
AI_LAYER_BASE_URL=https://routemate.workolik.com
|
||||||
|
|||||||
37
CUsersAdminAppDataLocalTempclaudeserver.log
Normal file
37
CUsersAdminAppDataLocalTempclaudeserver.log
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{"level":"INFO","time":"2026-06-30T15:23:23+05:30","caller":"utils/logger.go:24","msg":"Starting Doormile Backend..."}
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:23+05:30","caller":"utils/logger.go:24","msg":"Connecting to database","attempt":1,"host":"31.97.228.132","port":"5433"}
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:24+05:30","caller":"utils/logger.go:24","msg":"✅ Database connected successfully"}
|
||||||
|
redis: 2026/06/30 15:23:24 redis.go:478: auto mode fallback: maintnotifications disabled due to handshake error: ERR unknown subcommand 'maint_notifications'. Try CLIENT HELP.
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:24+05:30","caller":"utils/logger.go:24","msg":"✅ Redis connected successfully","addr":"31.97.228.132:6379"}
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:24+05:30","caller":"utils/logger.go:24","msg":"✅ NATS JetStream connected successfully","url":"nats://66.116.226.161:4223"}
|
||||||
|
{"level":"WARN","time":"2026-06-30T15:23:24+05:30","caller":"utils/logger.go:36","msg":"FCM: FIREBASE_SERVICE_ACCOUNT_PATH not set — push notifications disabled"}
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:24+05:30","caller":"utils/logger.go:24","msg":"Starting database auto-migrations for 21 logistics tables..."}
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:45+05:30","caller":"utils/logger.go:24","msg":"✅ Database migration completed successfully!"}
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:45+05:30","caller":"utils/logger.go:24","msg":"✅ context_embedding vector column ready"}
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:45+05:30","caller":"utils/logger.go:24","msg":"✅ ivfflat index on context_embedding ready"}
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:45+05:30","caller":"utils/logger.go:24","msg":"WarmPricingCache: warming pricing cache from Postgres..."}
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"Pricing cache warmed","zone":"Local","servicetype":"Normal","count":10}
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"Pricing cache warmed","zone":"Local","servicetype":"Express","count":9}
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"Pricing cache warmed","zone":"Regional","servicetype":"Normal","count":9}
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"Pricing cache warmed","zone":"Regional","servicetype":"Express","count":9}
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"Pricing cache warmed","zone":"National","servicetype":"Normal","count":9}
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"Pricing cache warmed","zone":"National","servicetype":"Express","count":9}
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"WarmPricingCache: done","slabs_warmed":6}
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"Server starting","port":"8081"}
|
||||||
|
|
||||||
|
┌───────────────────────────────────────────────────┐
|
||||||
|
│ Doormile Logistics Service API v1 │
|
||||||
|
│ Fiber v2.52.10 │
|
||||||
|
│ http://127.0.0.1:8081 │
|
||||||
|
│ (bound on host 0.0.0.0 and port 8081) │
|
||||||
|
│ │
|
||||||
|
│ Handlers ........... 206 Processes ........... 1 │
|
||||||
|
│ Prefork ....... Disabled PID .............. 9448 │
|
||||||
|
└───────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"BookingWorker: subscribed","subject":"api.v1.bookings.create","consumer":"bookings_create"}
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"BookingWorker: subscribed","subject":"api.v1.bookings.update","consumer":"bookings_update"}
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"BookingWorker: subscribed","subject":"api.v1.bookings.cancel","consumer":"bookings_cancel"}
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"BookingWorker: running, waiting for messages..."}
|
||||||
|
{"level":"INFO","time":"2026-06-30T15:26:06+05:30","caller":"utils/logger.go:24","msg":"API request success","method":"GET","path":"/api/v1/health","status":200,"latency":0}
|
||||||
|
exit status 0xffffffff
|
||||||
@@ -4,7 +4,7 @@ FROM golang:1.25
|
|||||||
RUN mkdir /app
|
RUN mkdir /app
|
||||||
ADD . /app/
|
ADD . /app/
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server .
|
RUN CGO_ENABLED=0 GOOS=linux go build -o server .
|
||||||
|
|
||||||
# Second Stage: Run the compiled binary inside alpine
|
# Second Stage: Run the compiled binary inside alpine
|
||||||
FROM alpine
|
FROM alpine
|
||||||
|
|||||||
@@ -5,40 +5,42 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Env string
|
Env string
|
||||||
Port string
|
Port string
|
||||||
DBName string
|
DBName string
|
||||||
DBUser string
|
DBUser string
|
||||||
DBPassword string
|
DBPassword string
|
||||||
DBPort string
|
DBPort string
|
||||||
DBHost string
|
DBHost string
|
||||||
RedisHost string
|
RedisHost string
|
||||||
RedisPort string
|
RedisPort string
|
||||||
RedisUser string
|
RedisUser string
|
||||||
RedisPassword string
|
RedisPassword string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
NatsURL string
|
NatsURL string
|
||||||
NatsUser string
|
NatsUser string
|
||||||
NatsPassword string
|
NatsPassword string
|
||||||
|
AILayerBaseURL string // AI decision-engine service base URL (e.g. http://rider-api:8082)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Env: getEnv("ENV", "development"),
|
Env: getEnv("ENV", "development"),
|
||||||
Port: getEnv("APP_PORT", "8081"),
|
Port: getEnv("APP_PORT", "8081"),
|
||||||
DBName: getEnv("DB_NAME", "logistics"),
|
DBName: getEnv("DB_NAME", "logistics"),
|
||||||
DBUser: getEnv("DB_USER", "admin"),
|
DBUser: getEnv("DB_USER", "admin"),
|
||||||
DBPassword: getEnv("DB_PASSWORD", "Package@321#"),
|
DBPassword: getEnv("DB_PASSWORD", "Package@321#"),
|
||||||
DBPort: getEnv("DB_PORT", "5433"),
|
DBPort: getEnv("DB_PORT", "5433"),
|
||||||
DBHost: getEnv("DB_HOST", "127.0.0.1"),
|
DBHost: getEnv("DB_HOST", "127.0.0.1"),
|
||||||
RedisHost: getEnv("REDIS_HOST", "127.0.0.1"),
|
RedisHost: getEnv("REDIS_HOST", "127.0.0.1"),
|
||||||
RedisPort: getEnv("REDIS_PORT", "6379"),
|
RedisPort: getEnv("REDIS_PORT", "6379"),
|
||||||
RedisUser: getEnv("REDIS_USER", ""),
|
RedisUser: getEnv("REDIS_USER", ""),
|
||||||
RedisPassword: getEnv("REDIS_PASSWORD", ""),
|
RedisPassword: getEnv("REDIS_PASSWORD", ""),
|
||||||
JWTSecret: getEnv("JWT_SECRET_KEY", "DoormileSuperSecretJWTKey2026!"),
|
JWTSecret: getEnv("JWT_SECRET_KEY", "DoormileSuperSecretJWTKey2026!"),
|
||||||
NatsURL: getEnv("NATS_URL", "nats://66.116.226.161:4223"),
|
NatsURL: getEnv("NATS_URL", "nats://66.116.226.161:4223"),
|
||||||
NatsUser: getEnv("NATS_USER", "doormile"),
|
NatsUser: getEnv("NATS_USER", "doormile"),
|
||||||
NatsPassword: getEnv("NATS_PASSWORD", "Package@321#"),
|
NatsPassword: getEnv("NATS_PASSWORD", "Package@321#"),
|
||||||
|
AILayerBaseURL: getEnv("AI_LAYER_BASE_URL", "https://routemate.workolik.com"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const (
|
|||||||
// Tripsheet Statuses
|
// Tripsheet Statuses
|
||||||
const (
|
const (
|
||||||
TripsheetDraft = "Draft"
|
TripsheetDraft = "Draft"
|
||||||
|
TripsheetReady = "Ready"
|
||||||
TripsheetDispatched = "Dispatched"
|
TripsheetDispatched = "Dispatched"
|
||||||
TripsheetArrived = "Arrived"
|
TripsheetArrived = "Arrived"
|
||||||
TripsheetCancelled = "Cancelled"
|
TripsheetCancelled = "Cancelled"
|
||||||
|
|||||||
@@ -818,6 +818,7 @@ func UpdateMiler(c *fiber.Ctx) error {
|
|||||||
type MilerUpdate struct {
|
type MilerUpdate struct {
|
||||||
Displayname string `json:"displayname"`
|
Displayname string `json:"displayname"`
|
||||||
Defaultvehicletype string `json:"defaultvehicletype"`
|
Defaultvehicletype string `json:"defaultvehicletype"`
|
||||||
|
Hubid *int `json:"hubid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
req := new(MilerUpdate)
|
req := new(MilerUpdate)
|
||||||
@@ -831,6 +832,9 @@ func UpdateMiler(c *fiber.Ctx) error {
|
|||||||
if req.Defaultvehicletype != "" {
|
if req.Defaultvehicletype != "" {
|
||||||
profile.Defaultvehicletype = req.Defaultvehicletype
|
profile.Defaultvehicletype = req.Defaultvehicletype
|
||||||
}
|
}
|
||||||
|
if req.Hubid != nil {
|
||||||
|
profile.Hubid = req.Hubid
|
||||||
|
}
|
||||||
profile.Updatedat = time.Now()
|
profile.Updatedat = time.Now()
|
||||||
|
|
||||||
if err := db.DB.Save(&profile).Error; err != nil {
|
if err := db.DB.Save(&profile).Error; err != nil {
|
||||||
|
|||||||
593
controllers/hubController.go
Normal file
593
controllers/hubController.go
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"doormile/config"
|
||||||
|
"doormile/constants"
|
||||||
|
"doormile/db"
|
||||||
|
"doormile/dto"
|
||||||
|
"doormile/models"
|
||||||
|
"doormile/utils"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// todayMidnight returns the start of the current day in server local time,
|
||||||
|
// used to scope "today" counters on the hub dashboard.
|
||||||
|
func todayMidnight() time.Time {
|
||||||
|
now := time.Now()
|
||||||
|
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
|
}
|
||||||
|
|
||||||
|
// hubPincodePrefix returns the first 3 digits of a hub's pincode, following
|
||||||
|
// the same zone-prefix convention used in pricing_helpers.go and city_gate.go.
|
||||||
|
func hubPincodePrefix(hubID int) string {
|
||||||
|
var hub models.Hub
|
||||||
|
if err := db.DB.Where("hubid = ?", hubID).First(&hub).Error; err != nil || len(hub.Pincode) < 3 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return hub.Pincode[:3]
|
||||||
|
}
|
||||||
|
|
||||||
|
func HubStaffLogin(cfg *config.Config) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
type LoginRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
req := new(LoginRequest)
|
||||||
|
if err := c.BodyParser(req); err != nil {
|
||||||
|
return utils.BadRequest(c, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Email == "" || req.Password == "" {
|
||||||
|
return utils.BadRequest(c, "email and password are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var staff models.HubStaffAccount
|
||||||
|
if err := db.DB.Where("email = ? AND isactive = ?", req.Email, true).First(&staff).Error; err != nil {
|
||||||
|
return utils.Unauthorized(c, "incorrect email or password")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !utils.CheckPasswordHash(req.Password, staff.Passwordhash) {
|
||||||
|
return utils.Unauthorized(c, "incorrect email or password")
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := utils.GenerateHubStaffToken(staff.Hubstaffaccountid, staff.Email, staff.Hubid, cfg.JWTSecret)
|
||||||
|
if err != nil {
|
||||||
|
return utils.Internal(c, "failed to generate token")
|
||||||
|
}
|
||||||
|
|
||||||
|
var hub models.Hub
|
||||||
|
db.DB.Where("hubid = ?", staff.Hubid).First(&hub)
|
||||||
|
|
||||||
|
var loc models.AppLocation
|
||||||
|
db.DB.Where("applocationid = ?", hub.Applocationid).First(&loc)
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"success": true,
|
||||||
|
"token": token,
|
||||||
|
"hub": fiber.Map{
|
||||||
|
"hubid": hub.Hubid,
|
||||||
|
"hubname": hub.Hubname,
|
||||||
|
"hubtype": hub.Hubtype,
|
||||||
|
"city": loc.Applocationname,
|
||||||
|
"capacity": hub.Capacity,
|
||||||
|
},
|
||||||
|
"staff": fiber.Map{
|
||||||
|
"displayname": staff.Displayname,
|
||||||
|
"email": staff.Email,
|
||||||
|
"role": staff.Roleid,
|
||||||
|
},
|
||||||
|
"is_doormile_staff": staff.Tenantid == nil,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCurrentHubStaff reloads the requesting hub staff account fresh from the
|
||||||
|
// DB (rather than trusting JWT claims) so tenantid changes take effect
|
||||||
|
// immediately without requiring the staff member to re-login.
|
||||||
|
func getCurrentHubStaff(c *fiber.Ctx) (*models.HubStaffAccount, error) {
|
||||||
|
staffID := c.Locals("userid").(int)
|
||||||
|
var staff models.HubStaffAccount
|
||||||
|
if err := db.DB.Where("hubstaffaccountid = ?", staffID).First(&staff).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &staff, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDoormileStaff(staff *models.HubStaffAccount) bool {
|
||||||
|
return staff.Tenantid == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetHubDashboardStats(c *fiber.Ctx) error {
|
||||||
|
hubID := c.Locals("hubid").(int)
|
||||||
|
midnight := todayMidnight()
|
||||||
|
prefix := hubPincodePrefix(hubID)
|
||||||
|
|
||||||
|
var parcelsReceivedToday int64
|
||||||
|
db.DB.Model(&models.Consignment{}).
|
||||||
|
Where("currenthubid = ? AND status = ? AND updatedat >= ?", hubID, constants.ConsignmentInwardedAtHub, midnight).
|
||||||
|
Count(&parcelsReceivedToday)
|
||||||
|
|
||||||
|
var milersAvailable int64
|
||||||
|
db.DB.Model(&models.MilerProfile{}).
|
||||||
|
Where("hubid = ? AND availabilitystatus = ?", hubID, constants.MilerAvailable).
|
||||||
|
Count(&milersAvailable)
|
||||||
|
|
||||||
|
var milersOnDuty int64
|
||||||
|
db.DB.Model(&models.MilerProfile{}).
|
||||||
|
Where("hubid = ? AND availabilitystatus != ?", hubID, constants.MilerOffline).
|
||||||
|
Count(&milersOnDuty)
|
||||||
|
|
||||||
|
pendingQuery := db.DB.Model(&models.PickupBooking{}).Where("status = ?", constants.BookingPendingPickup)
|
||||||
|
if prefix != "" {
|
||||||
|
pendingQuery = pendingQuery.Where("pickuppincode LIKE ?", prefix+"%")
|
||||||
|
}
|
||||||
|
var pendingPickups int64
|
||||||
|
pendingQuery.Count(&pendingPickups)
|
||||||
|
|
||||||
|
var batchesSentToday int64
|
||||||
|
db.DB.Model(&models.Tripsheet{}).
|
||||||
|
Where("sourcehubid = ? AND dispatchtime >= ?", hubID, midnight).
|
||||||
|
Count(&batchesSentToday)
|
||||||
|
|
||||||
|
var exceptions int64
|
||||||
|
db.DB.Model(&models.ConsignmentException{}).
|
||||||
|
Where("hubid = ? AND createdat >= ?", hubID, midnight).
|
||||||
|
Count(&exceptions)
|
||||||
|
|
||||||
|
return utils.OK(c, fiber.Map{
|
||||||
|
"parcels_received_today": parcelsReceivedToday,
|
||||||
|
"milers_available": milersAvailable,
|
||||||
|
"milers_on_duty": milersOnDuty,
|
||||||
|
"pending_pickups": pendingPickups,
|
||||||
|
"batches_sent_today": batchesSentToday,
|
||||||
|
"exceptions": exceptions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetHubUnassignedBookings(c *fiber.Ctx) error {
|
||||||
|
hubID := c.Locals("hubid").(int)
|
||||||
|
prefix := hubPincodePrefix(hubID)
|
||||||
|
|
||||||
|
query := db.DB.Preload("Parcels").
|
||||||
|
Where("assignedmileruserid IS NULL AND status = ?", constants.BookingPendingPickup)
|
||||||
|
if prefix != "" {
|
||||||
|
query = query.Where("pickuppincode LIKE ?", prefix+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
var bookings []models.PickupBooking
|
||||||
|
if err := query.Order("createdat DESC").Find(&bookings).Error; err != nil {
|
||||||
|
return utils.Internal(c, "failed to fetch unassigned bookings")
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]fiber.Map, 0, len(bookings))
|
||||||
|
for _, b := range bookings {
|
||||||
|
var customer models.AppCustomer
|
||||||
|
db.DB.Where("appcustomerid = ?", b.Appcustomerid).First(&customer)
|
||||||
|
|
||||||
|
response = append(response, fiber.Map{
|
||||||
|
"bookingid": b.Bookingid,
|
||||||
|
"bookingno": b.Bookingno,
|
||||||
|
"customer_name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
|
||||||
|
"pickup_address": b.Pickupaddress,
|
||||||
|
"pickup_pincode": b.Pickuppincode,
|
||||||
|
"delivery_address": b.Deliveryaddress,
|
||||||
|
"parcels": b.Parcels,
|
||||||
|
"created_at": b.Createdat,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.List(c, response, int64(len(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetHubInboundToday(c *fiber.Ctx) error {
|
||||||
|
hubID := c.Locals("hubid").(int)
|
||||||
|
midnight := todayMidnight()
|
||||||
|
|
||||||
|
var consignments []models.Consignment
|
||||||
|
if err := db.DB.Where("currenthubid = ? AND status = ? AND updatedat >= ?", hubID, constants.ConsignmentInwardedAtHub, midnight).
|
||||||
|
Order("updatedat DESC").Find(&consignments).Error; err != nil {
|
||||||
|
return utils.Internal(c, "failed to fetch inbound consignments")
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.List(c, consignments, int64(len(consignments)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateInboundScan(c *fiber.Ctx) error {
|
||||||
|
hubID := c.Locals("hubid").(int)
|
||||||
|
idParam := c.Params("id")
|
||||||
|
|
||||||
|
type InboundRequest struct {
|
||||||
|
TrackingID string `json:"tracking_id"`
|
||||||
|
Condition string `json:"condition"`
|
||||||
|
Temperature string `json:"temperature"`
|
||||||
|
Shelf string `json:"shelf"`
|
||||||
|
Weight string `json:"weight"`
|
||||||
|
}
|
||||||
|
req := new(InboundRequest)
|
||||||
|
if err := c.BodyParser(req); err != nil {
|
||||||
|
return utils.BadRequest(c, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
var consignment models.Consignment
|
||||||
|
found := false
|
||||||
|
if id, err := strconv.Atoi(idParam); err == nil {
|
||||||
|
if db.DB.Where("consignmentid = ?", id).First(&consignment).Error == nil {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found && idParam != "" {
|
||||||
|
if db.DB.Where("trackingno = ?", idParam).First(&consignment).Error == nil {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found && req.TrackingID != "" {
|
||||||
|
if db.DB.Where("trackingno = ?", req.TrackingID).First(&consignment).Error == nil {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return utils.NotFound(c, "consignment not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
recommendedShelf := req.Shelf
|
||||||
|
if recommendedShelf == "" {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(req.Condition, "Damaged"):
|
||||||
|
recommendedShelf = "Exception Area"
|
||||||
|
case req.Temperature != "" && req.Temperature != "N/A":
|
||||||
|
recommendedShelf = "Zone C (Cold Room)"
|
||||||
|
default:
|
||||||
|
recommendedShelf = "Zone A"
|
||||||
|
if n := len(consignment.Deliverypincode); n > 0 {
|
||||||
|
lastDigit := consignment.Deliverypincode[n-1]
|
||||||
|
if lastDigit >= '0' && lastDigit <= '9' && (lastDigit-'0')%2 == 0 {
|
||||||
|
recommendedShelf = "Zone B"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
consignment.Status = constants.ConsignmentInwardedAtHub
|
||||||
|
consignment.Currenthubid = &hubID
|
||||||
|
consignment.Condition = req.Condition
|
||||||
|
consignment.Shelf = recommendedShelf
|
||||||
|
consignment.Updatedat = time.Now()
|
||||||
|
|
||||||
|
if err := db.DB.Save(&consignment).Error; err != nil {
|
||||||
|
return utils.Internal(c, "failed to update consignment")
|
||||||
|
}
|
||||||
|
|
||||||
|
history := models.ConsignmentHistory{
|
||||||
|
Consignmentid: consignment.Consignmentid,
|
||||||
|
Hubid: &hubID,
|
||||||
|
Eventstatus: constants.ConsignmentInwardedAtHub,
|
||||||
|
Remarks: fmt.Sprintf("Inbound scan at hub: condition=%s, shelf=%s", req.Condition, recommendedShelf),
|
||||||
|
}
|
||||||
|
db.DB.Create(&history)
|
||||||
|
|
||||||
|
if strings.Contains(req.Condition, "Damaged") {
|
||||||
|
exception := models.ConsignmentException{
|
||||||
|
Consignmentid: consignment.Consignmentid,
|
||||||
|
Hubid: &hubID,
|
||||||
|
Exceptiontype: constants.ExceptionDamaged,
|
||||||
|
Description: fmt.Sprintf("Flagged during hub inbound scan: %s", req.Condition),
|
||||||
|
}
|
||||||
|
db.DB.Create(&exception)
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.OK(c, fiber.Map{
|
||||||
|
"consignment": consignment,
|
||||||
|
"recommended_shelf": recommendedShelf,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------
|
||||||
|
// HUB BATCHES (DISPATCH) — backed by the existing Tripsheet model
|
||||||
|
// --------------------
|
||||||
|
|
||||||
|
func GetHubBatches(c *fiber.Ctx) error {
|
||||||
|
hubID := c.Locals("hubid").(int)
|
||||||
|
|
||||||
|
var tripsheets []models.Tripsheet
|
||||||
|
if err := db.DB.Where("sourcehubid = ? AND deletedat IS NULL", hubID).
|
||||||
|
Order("createdat DESC").Find(&tripsheets).Error; err != nil {
|
||||||
|
return utils.Internal(c, "failed to fetch batches")
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]fiber.Map, 0, len(tripsheets))
|
||||||
|
for _, ts := range tripsheets {
|
||||||
|
var itemCount int64
|
||||||
|
db.DB.Model(&models.TripsheetItem{}).Where("tripsheetid = ?", ts.Tripsheetid).Count(&itemCount)
|
||||||
|
|
||||||
|
response = append(response, fiber.Map{
|
||||||
|
"tripsheetid": ts.Tripsheetid,
|
||||||
|
"tripsheetno": ts.Tripsheetno,
|
||||||
|
"route": ts.Batchlabel,
|
||||||
|
"destination": ts.Destinationlabel,
|
||||||
|
"kind": ts.Batchkind,
|
||||||
|
"status": ts.Status,
|
||||||
|
"item_count": itemCount,
|
||||||
|
"dispatchtime": ts.Dispatchtime,
|
||||||
|
"createdat": ts.Createdat,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.List(c, response, int64(len(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveDestinationHubID best-effort matches a free-text destination (e.g.
|
||||||
|
// "Mumbai Hub (BOM-02)") against a real Hub row by name, since
|
||||||
|
// tripsheets.destinationhubid carries a NOT NULL foreign key to hubs. When no
|
||||||
|
// internal hub matches (interstate transfer to a city we don't operate a hub
|
||||||
|
// in yet), it falls back to the batch's own source hub, which is always a
|
||||||
|
// valid id; the human-readable destination is preserved in Destinationlabel.
|
||||||
|
func resolveDestinationHubID(destination string, fallback int) int {
|
||||||
|
if destination == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
var hub models.Hub
|
||||||
|
if err := db.DB.Where("hubname ILIKE ?", "%"+destination+"%").First(&hub).Error; err == nil {
|
||||||
|
return hub.Hubid
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateHubBatch(c *fiber.Ctx) error {
|
||||||
|
hubID := c.Locals("hubid").(int)
|
||||||
|
staffID := c.Locals("userid").(int)
|
||||||
|
|
||||||
|
type BatchRequest struct {
|
||||||
|
Route string `json:"route"`
|
||||||
|
Destination string `json:"destination"`
|
||||||
|
Vehicle string `json:"vehicle"`
|
||||||
|
ParcelsCount int `json:"parcels_count"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
}
|
||||||
|
req := new(BatchRequest)
|
||||||
|
if err := c.BodyParser(req); err != nil {
|
||||||
|
return utils.BadRequest(c, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Route == "" {
|
||||||
|
return utils.BadRequest(c, "route is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
kind := req.Kind
|
||||||
|
if kind == "" {
|
||||||
|
kind = "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
tripsheet := models.Tripsheet{
|
||||||
|
Tripsheetno: generateTripsheetNo(),
|
||||||
|
Sourcehubid: hubID,
|
||||||
|
Destinationhubid: resolveDestinationHubID(req.Destination, hubID),
|
||||||
|
Batchlabel: req.Route,
|
||||||
|
Batchkind: kind,
|
||||||
|
Destinationlabel: req.Destination,
|
||||||
|
Status: constants.TripsheetDraft,
|
||||||
|
Createdby: staffID,
|
||||||
|
Updatedby: staffID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.DB.Create(&tripsheet).Error; err != nil {
|
||||||
|
return utils.Internal(c, "failed to create batch")
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Created(c, fiber.Map{
|
||||||
|
"tripsheetid": tripsheet.Tripsheetid,
|
||||||
|
"tripsheetno": tripsheet.Tripsheetno,
|
||||||
|
"route": tripsheet.Batchlabel,
|
||||||
|
"destination": tripsheet.Destinationlabel,
|
||||||
|
"kind": tripsheet.Batchkind,
|
||||||
|
"status": tripsheet.Status,
|
||||||
|
"vehicle": req.Vehicle,
|
||||||
|
"parcels_count": req.ParcelsCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateBatchStatus(c *fiber.Ctx) error {
|
||||||
|
hubID := c.Locals("hubid").(int)
|
||||||
|
id, _ := strconv.Atoi(c.Params("id"))
|
||||||
|
|
||||||
|
type StatusUpdate struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
req := new(StatusUpdate)
|
||||||
|
if err := c.BodyParser(req); err != nil {
|
||||||
|
return utils.BadRequest(c, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
validStatuses := map[string]bool{
|
||||||
|
constants.TripsheetDraft: true,
|
||||||
|
constants.TripsheetReady: true,
|
||||||
|
constants.TripsheetDispatched: true,
|
||||||
|
}
|
||||||
|
if !validStatuses[req.Status] {
|
||||||
|
return utils.BadRequest(c, "status must be one of Draft, Ready, Dispatched")
|
||||||
|
}
|
||||||
|
|
||||||
|
var tripsheet models.Tripsheet
|
||||||
|
if err := db.DB.Where("tripsheetid = ? AND sourcehubid = ? AND deletedat IS NULL", id, hubID).First(&tripsheet).Error; err != nil {
|
||||||
|
return utils.NotFound(c, "batch not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
tripsheet.Status = req.Status
|
||||||
|
tripsheet.Updatedat = time.Now()
|
||||||
|
if req.Status == constants.TripsheetDispatched {
|
||||||
|
now := time.Now()
|
||||||
|
tripsheet.Dispatchtime = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.DB.Save(&tripsheet).Error; err != nil {
|
||||||
|
return utils.Internal(c, "failed to update batch status")
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.OK(c, tripsheet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHubMilers is the hub-scoped equivalent of admin's GetMilers, filtered to
|
||||||
|
// only the milers assigned to the requesting hub staff's own hub.
|
||||||
|
func GetHubMilers(c *fiber.Ctx) error {
|
||||||
|
hubID := c.Locals("hubid").(int)
|
||||||
|
|
||||||
|
var profiles []models.MilerProfile
|
||||||
|
if err := db.DB.Where("hubid = ?", hubID).Find(&profiles).Error; err != nil {
|
||||||
|
return utils.Internal(c, "failed to fetch milers")
|
||||||
|
}
|
||||||
|
return utils.List(c, profiles, int64(len(profiles)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------
|
||||||
|
// HUB MANAGEMENT (Doormile staff only)
|
||||||
|
// --------------------
|
||||||
|
|
||||||
|
// CreateHubStaffAccount lets Doormile staff (tenantid == nil) provision new
|
||||||
|
// hub staff accounts, including partner-tenant accounts scoped to one hub.
|
||||||
|
func CreateHubStaffAccount(c *fiber.Ctx) error {
|
||||||
|
current, err := getCurrentHubStaff(c)
|
||||||
|
if err != nil {
|
||||||
|
return utils.Internal(c, "failed to verify requesting staff account")
|
||||||
|
}
|
||||||
|
if !isDoormileStaff(current) {
|
||||||
|
return utils.Forbidden(c, "only Doormile staff can create hub staff accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateStaffRequest struct {
|
||||||
|
Hubid int `json:"hubid"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Displayname string `json:"displayname"`
|
||||||
|
Tenantid *int `json:"tenantid"`
|
||||||
|
}
|
||||||
|
req := new(CreateStaffRequest)
|
||||||
|
if err := c.BodyParser(req); err != nil {
|
||||||
|
return utils.BadRequest(c, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Hubid == 0 || req.Email == "" || req.Password == "" {
|
||||||
|
return utils.BadRequest(c, "hubid, email and password are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var hub models.Hub
|
||||||
|
if err := db.DB.Where("hubid = ? AND deletedat IS NULL", req.Hubid).First(&hub).Error; err != nil {
|
||||||
|
return utils.NotFound(c, "hub not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
passHash, err := utils.HashPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
return utils.Internal(c, "failed to hash password")
|
||||||
|
}
|
||||||
|
|
||||||
|
staff := models.HubStaffAccount{
|
||||||
|
Hubid: req.Hubid,
|
||||||
|
Email: req.Email,
|
||||||
|
Passwordhash: passHash,
|
||||||
|
Displayname: req.Displayname,
|
||||||
|
Roleid: 6,
|
||||||
|
Isactive: true,
|
||||||
|
Tenantid: req.Tenantid,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.DB.Create(&staff).Error; err != nil {
|
||||||
|
return utils.Internal(c, "failed to create hub staff account")
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Created(c, fiber.Map{
|
||||||
|
"hubstaffaccountid": staff.Hubstaffaccountid,
|
||||||
|
"hubid": staff.Hubid,
|
||||||
|
"email": staff.Email,
|
||||||
|
"displayname": staff.Displayname,
|
||||||
|
"tenantid": staff.Tenantid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHubsInCity lists every hub in the requesting staff's city (same
|
||||||
|
// applocationid as their own hub), noting whether each already has a staff
|
||||||
|
// account so the console can flag hubs still needing one.
|
||||||
|
func GetHubsInCity(c *fiber.Ctx) error {
|
||||||
|
hubID := c.Locals("hubid").(int)
|
||||||
|
|
||||||
|
var myHub models.Hub
|
||||||
|
if err := db.DB.Where("hubid = ?", hubID).First(&myHub).Error; err != nil {
|
||||||
|
return utils.Internal(c, "failed to resolve current hub")
|
||||||
|
}
|
||||||
|
|
||||||
|
var hubs []models.Hub
|
||||||
|
if err := db.DB.Where("applocationid = ? AND deletedat IS NULL", myHub.Applocationid).
|
||||||
|
Order("hubid").Find(&hubs).Error; err != nil {
|
||||||
|
return utils.Internal(c, "failed to fetch hubs")
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]fiber.Map, 0, len(hubs))
|
||||||
|
for _, h := range hubs {
|
||||||
|
var staffCount int64
|
||||||
|
db.DB.Model(&models.HubStaffAccount{}).Where("hubid = ?", h.Hubid).Count(&staffCount)
|
||||||
|
|
||||||
|
response = append(response, fiber.Map{
|
||||||
|
"hubid": h.Hubid,
|
||||||
|
"hubname": h.Hubname,
|
||||||
|
"hubtype": h.Hubtype,
|
||||||
|
"pincode": h.Pincode,
|
||||||
|
"status": h.Status,
|
||||||
|
"capacity": h.Capacity,
|
||||||
|
"has_staff": staffCount > 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.List(c, response, int64(len(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCityHub lets Doormile staff add a new hub, forced into their own
|
||||||
|
// city (applocationid) regardless of what the request body sends.
|
||||||
|
func CreateCityHub(c *fiber.Ctx) error {
|
||||||
|
current, err := getCurrentHubStaff(c)
|
||||||
|
if err != nil {
|
||||||
|
return utils.Internal(c, "failed to verify requesting staff account")
|
||||||
|
}
|
||||||
|
if !isDoormileStaff(current) {
|
||||||
|
return utils.Forbidden(c, "only Doormile staff can create new hubs")
|
||||||
|
}
|
||||||
|
|
||||||
|
hubID := c.Locals("hubid").(int)
|
||||||
|
var myHub models.Hub
|
||||||
|
if err := db.DB.Where("hubid = ?", hubID).First(&myHub).Error; err != nil {
|
||||||
|
return utils.Internal(c, "failed to resolve current hub")
|
||||||
|
}
|
||||||
|
|
||||||
|
req := new(dto.HubCreateRequest)
|
||||||
|
if err := c.BodyParser(req); err != nil {
|
||||||
|
return utils.BadRequest(c, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Hubname == "" || req.Hubtype == "" {
|
||||||
|
return utils.BadRequest(c, "hubname and hubtype are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
hub := models.Hub{
|
||||||
|
Hubname: req.Hubname,
|
||||||
|
Hubtype: req.Hubtype,
|
||||||
|
Applocationid: myHub.Applocationid,
|
||||||
|
Contactno: req.Contactno,
|
||||||
|
Address: req.Address,
|
||||||
|
Latitude: req.Latitude,
|
||||||
|
Longitude: req.Longitude,
|
||||||
|
Pincode: req.Pincode,
|
||||||
|
Status: req.Status,
|
||||||
|
}
|
||||||
|
if hub.Status == "" {
|
||||||
|
hub.Status = "Active"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.DB.Create(&hub).Error; err != nil {
|
||||||
|
return utils.Internal(c, "failed to create hub")
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Created(c, hub)
|
||||||
|
}
|
||||||
330
internal/assignment/ai_layer.go
Normal file
330
internal/assignment/ai_layer.go
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
package assignment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"doormile/constants"
|
||||||
|
"doormile/db"
|
||||||
|
"doormile/models"
|
||||||
|
"doormile/utils"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Request / response types ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type aiCandidate struct {
|
||||||
|
MilerID int `json:"miler_id"`
|
||||||
|
DistanceKm float64 `json:"distance_km"`
|
||||||
|
Rating float64 `json:"rating"`
|
||||||
|
ActiveBookings int64 `json:"active_bookings"`
|
||||||
|
OnTimeRate30d float64 `json:"on_time_rate_30d"`
|
||||||
|
CompletedToday int64 `json:"completed_today"`
|
||||||
|
HubID int `json:"hub_id"`
|
||||||
|
HubLoad int64 `json:"hub_load"`
|
||||||
|
HubCapacity int `json:"hub_capacity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type aiDecisionRequest struct {
|
||||||
|
Booking aiBookingInfo `json:"booking"`
|
||||||
|
Candidates []aiCandidate `json:"candidates"`
|
||||||
|
Context aiRequestCtx `json:"context"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type aiBookingInfo struct {
|
||||||
|
PickupLat float64 `json:"pickup_lat"`
|
||||||
|
PickupLon float64 `json:"pickup_lon"`
|
||||||
|
DeliveryLat float64 `json:"delivery_lat"`
|
||||||
|
DeliveryLon float64 `json:"delivery_lon"`
|
||||||
|
ItemCategory string `json:"item_category"`
|
||||||
|
Weight float64 `json:"weight"`
|
||||||
|
ServiceType string `json:"service_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type aiRequestCtx struct {
|
||||||
|
Hour int `json:"hour"`
|
||||||
|
IsPeak bool `json:"is_peak"`
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type aiDecisionResponse struct {
|
||||||
|
ChosenMilerID int `json:"chosen_miler_id"`
|
||||||
|
Escalate bool `json:"escalate"`
|
||||||
|
AgentDecisionID uint64 `json:"agent_decision_id"`
|
||||||
|
Reasoning string `json:"reasoning"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main entry point ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// selectMilerWithAI collects all eligible milers from the GEORADIUS result, calls
|
||||||
|
// the AI decision engine, and returns the chosen miler plus the decision ID for
|
||||||
|
// audit. Falls back to the original distance/load/rating formula when the AI
|
||||||
|
// layer is unreachable or times out. Returns (nil, nil, false) when there are no
|
||||||
|
// eligible candidates or when the AI layer escalates the booking.
|
||||||
|
func selectMilerWithAI(booking *models.PickupBooking, nearby []redis.GeoLocation) (*milerCandidate, *uint64, bool) {
|
||||||
|
candidates, aiCandidates := collectEligibleCandidates(nearby)
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
decision, err := callDecisionEngine(booking, aiCandidates)
|
||||||
|
if err != nil {
|
||||||
|
utils.Warn("AI_LAYER_FALLBACK: decide-assignment unreachable, using legacy scoring",
|
||||||
|
"booking_id", booking.Bookingid, "error", err)
|
||||||
|
best := pickBestFromCandidates(candidates)
|
||||||
|
return best, nil, best != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Info("Assignment: AI layer responded",
|
||||||
|
"booking_id", booking.Bookingid,
|
||||||
|
"escalate", decision.Escalate,
|
||||||
|
"chosen_miler_id", decision.ChosenMilerID,
|
||||||
|
"agent_decision_id", decision.AgentDecisionID,
|
||||||
|
"reasoning", decision.Reasoning,
|
||||||
|
)
|
||||||
|
|
||||||
|
if decision.Escalate {
|
||||||
|
utils.Warn("Assignment: AI layer escalated — skipping assignment this attempt",
|
||||||
|
"booking_id", booking.Bookingid,
|
||||||
|
"reasoning", decision.Reasoning,
|
||||||
|
)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var decisionID *uint64
|
||||||
|
if decision.AgentDecisionID != 0 {
|
||||||
|
id := decision.AgentDecisionID
|
||||||
|
decisionID = &id
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range candidates {
|
||||||
|
if c.profile.Userid == decision.ChosenMilerID {
|
||||||
|
return c, decisionID, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI returned an ID that is not in our eligibility set — fall back safely.
|
||||||
|
utils.Warn("AI_LAYER_FALLBACK: chosen miler not in eligible set, using legacy scoring",
|
||||||
|
"booking_id", booking.Bookingid,
|
||||||
|
"chosen_miler_id", decision.ChosenMilerID,
|
||||||
|
)
|
||||||
|
best := pickBestFromCandidates(candidates)
|
||||||
|
return best, nil, best != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Candidate collection ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// collectEligibleCandidates iterates the GEORADIUS result, applies eligibility
|
||||||
|
// filters (availability, active-booking cap), and fetches per-miler stats needed
|
||||||
|
// by the AI layer. Returns parallel slices so callers can use either.
|
||||||
|
func collectEligibleCandidates(nearby []redis.GeoLocation) ([]*milerCandidate, []aiCandidate) {
|
||||||
|
var candidates []*milerCandidate
|
||||||
|
var aiCandidates []aiCandidate
|
||||||
|
|
||||||
|
for _, loc := range nearby {
|
||||||
|
milerUserID, err := strconv.Atoi(loc.Name)
|
||||||
|
if err != nil {
|
||||||
|
utils.Warn("Assignment: skipping non-numeric GEO member", "name", loc.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile models.MilerProfile
|
||||||
|
if err := db.DB.Where("userid = ?", milerUserID).First(&profile).Error; err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if profile.Availabilitystatus != constants.MilerAvailable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeCount int64
|
||||||
|
db.DB.Model(&models.BookingAssignment{}).
|
||||||
|
Where("mileruserid = ? AND assignmentstatus IN ?", milerUserID, []string{
|
||||||
|
constants.AssignmentAssigned,
|
||||||
|
constants.AssignmentAccepted,
|
||||||
|
}).
|
||||||
|
Count(&activeCount)
|
||||||
|
|
||||||
|
if activeCount >= maxActive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
onTimeRate, completedToday := fetchMilerStats(milerUserID)
|
||||||
|
hubID, hubLoad, hubCapacity := fetchHubData(profile.Hubid)
|
||||||
|
|
||||||
|
candidates = append(candidates, &milerCandidate{
|
||||||
|
profile: profile,
|
||||||
|
distanceKm: loc.Dist,
|
||||||
|
activeBookings: activeCount,
|
||||||
|
})
|
||||||
|
aiCandidates = append(aiCandidates, aiCandidate{
|
||||||
|
MilerID: milerUserID,
|
||||||
|
DistanceKm: loc.Dist,
|
||||||
|
Rating: profile.Rating,
|
||||||
|
ActiveBookings: activeCount,
|
||||||
|
OnTimeRate30d: onTimeRate,
|
||||||
|
CompletedToday: completedToday,
|
||||||
|
HubID: hubID,
|
||||||
|
HubLoad: hubLoad,
|
||||||
|
HubCapacity: hubCapacity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates, aiCandidates
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Per-miler stats ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func fetchMilerStats(milerUserID int) (onTimeRate float64, completedToday int64) {
|
||||||
|
type statsRow struct {
|
||||||
|
Total int64
|
||||||
|
OnTime int64
|
||||||
|
}
|
||||||
|
var row statsRow
|
||||||
|
db.DB.Raw(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total,
|
||||||
|
COUNT(CASE WHEN assignmentstatus = 'Completed_OnTime' THEN 1 END) AS on_time
|
||||||
|
FROM bookingassignments
|
||||||
|
WHERE mileruserid = ?
|
||||||
|
AND createdat > NOW() - INTERVAL '30 days'
|
||||||
|
`, milerUserID).Scan(&row)
|
||||||
|
|
||||||
|
if row.Total == 0 {
|
||||||
|
onTimeRate = 0.85 // neutral assumption for milers with no recent history
|
||||||
|
} else {
|
||||||
|
onTimeRate = float64(row.OnTime) / float64(row.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
today := time.Now().Truncate(24 * time.Hour)
|
||||||
|
db.DB.Model(&models.BookingAssignment{}).
|
||||||
|
Where("mileruserid = ? AND createdat >= ? AND assignmentstatus = ?",
|
||||||
|
milerUserID, today, constants.AssignmentCompleted).
|
||||||
|
Count(&completedToday)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hub stats ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// fetchHubData returns the hub_load (active assignments across the hub) and
|
||||||
|
// hub_capacity for the given hub. Defaults to 50 for capacity when the column
|
||||||
|
// doesn't exist or the miler has no assigned hub.
|
||||||
|
func fetchHubData(hubID *int) (resolvedHubID int, hubLoad int64, hubCapacity int) {
|
||||||
|
hubCapacity = 50 // safe default — also used if hubs.capacity column is absent
|
||||||
|
if hubID == nil {
|
||||||
|
return 0, 0, hubCapacity
|
||||||
|
}
|
||||||
|
resolvedHubID = *hubID
|
||||||
|
|
||||||
|
// hub_load: total active assignments whose miler belongs to this hub
|
||||||
|
db.DB.Raw(`
|
||||||
|
SELECT COUNT(*) FROM bookingassignments ba
|
||||||
|
JOIN milerprofiles mp ON ba.mileruserid = mp.userid
|
||||||
|
WHERE mp.hubid = ?
|
||||||
|
AND ba.assignmentstatus IN ('Assigned', 'Accepted', 'Pickup_Scheduled')
|
||||||
|
`, resolvedHubID).Scan(&hubLoad)
|
||||||
|
|
||||||
|
// hub_capacity: gracefully handle column not existing yet
|
||||||
|
var cap int
|
||||||
|
if err := db.DB.Raw(`SELECT capacity FROM hubs WHERE hubid = ?`, resolvedHubID).Scan(&cap).Error; err == nil && cap > 0 {
|
||||||
|
hubCapacity = cap
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Fallback scorer ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// pickBestFromCandidates applies the original formula to an already-collected
|
||||||
|
// eligible slice: score = distance_km*1.0 + active_bookings*2.0 - rating*0.5
|
||||||
|
func pickBestFromCandidates(candidates []*milerCandidate) *milerCandidate {
|
||||||
|
var best *milerCandidate
|
||||||
|
bestScore := 1e18
|
||||||
|
|
||||||
|
for _, c := range candidates {
|
||||||
|
score := c.distanceKm*1.0 + float64(c.activeBookings)*2.0 - c.profile.Rating*0.5
|
||||||
|
if score < bestScore {
|
||||||
|
bestScore = score
|
||||||
|
best = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AI layer HTTP call ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func callDecisionEngine(booking *models.PickupBooking, candidates []aiCandidate) (aiDecisionResponse, error) {
|
||||||
|
baseURL := os.Getenv("AI_LAYER_BASE_URL")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://routemate.workolik.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
hour := now.Hour()
|
||||||
|
isPeak := (hour >= 7 && hour <= 10) || (hour >= 17 && hour <= 21)
|
||||||
|
|
||||||
|
reqBody := aiDecisionRequest{
|
||||||
|
Booking: aiBookingInfo{
|
||||||
|
PickupLat: booking.Pickuplatitude,
|
||||||
|
PickupLon: booking.Pickuplongitude,
|
||||||
|
DeliveryLat: booking.Deliverylatitude,
|
||||||
|
DeliveryLon: booking.Deliverylongitude,
|
||||||
|
ItemCategory: b2cFirstParcelCategory(booking),
|
||||||
|
Weight: b2cChargeableWeight(booking),
|
||||||
|
ServiceType: b2cFirstServiceType(booking),
|
||||||
|
},
|
||||||
|
Candidates: candidates,
|
||||||
|
Context: aiRequestCtx{
|
||||||
|
Hour: hour,
|
||||||
|
IsPeak: isPeak,
|
||||||
|
Zone: booking.Deliverycity,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return aiDecisionResponse{}, fmt.Errorf("marshal AI request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Info("Assignment: AI layer outgoing payload",
|
||||||
|
"booking_id", booking.Bookingid,
|
||||||
|
"payload", string(body),
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
baseURL+"/api/v1/doormile/decide-assignment",
|
||||||
|
bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return aiDecisionResponse{}, fmt.Errorf("build AI request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return aiDecisionResponse{}, fmt.Errorf("AI layer call: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return aiDecisionResponse{}, fmt.Errorf("AI layer HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decision aiDecisionResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&decision); err != nil {
|
||||||
|
return aiDecisionResponse{}, fmt.Errorf("decode AI response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return decision, nil
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"doormile/constants"
|
"doormile/constants"
|
||||||
@@ -74,7 +73,7 @@ func AssignCRMMiler(bookingID int) {
|
|||||||
// Returns (false, err) on hard errors (booking missing, DB failure).
|
// Returns (false, err) on hard errors (booking missing, DB failure).
|
||||||
func tryAssign(bookingID int) (bool, error) {
|
func tryAssign(bookingID int) (bool, error) {
|
||||||
var booking models.PickupBooking
|
var booking models.PickupBooking
|
||||||
if err := db.DB.First(&booking, bookingID).Error; err != nil {
|
if err := db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, bookingID).Error; err != nil {
|
||||||
return false, fmt.Errorf("load booking: %w", err)
|
return false, fmt.Errorf("load booking: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,12 +99,12 @@ func tryAssign(bookingID int) (bool, error) {
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
candidate, found := pickBestMiler(nearby)
|
candidate, agentDecisionID, found := selectMilerWithAI(&booking, nearby)
|
||||||
if !found {
|
if !found {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := commitAssignment(&booking, candidate); err != nil {
|
if err := commitAssignment(&booking, candidate, agentDecisionID); err != nil {
|
||||||
return false, fmt.Errorf("commit: %w", err)
|
return false, fmt.Errorf("commit: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,58 +139,9 @@ func queryNearbyMilers(lat, lon float64) ([]redis.GeoLocation, error) {
|
|||||||
return locs, nil
|
return locs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickBestMiler filters the nearby list for eligibility, then returns the candidate
|
|
||||||
// with the lowest score. score = distance_km*1.0 + active_bookings*2.0 - rating*0.5
|
|
||||||
func pickBestMiler(nearby []redis.GeoLocation) (*milerCandidate, bool) {
|
|
||||||
var best *milerCandidate
|
|
||||||
bestScore := 1e18
|
|
||||||
|
|
||||||
for _, loc := range nearby {
|
|
||||||
milerUserID, err := strconv.Atoi(loc.Name)
|
|
||||||
if err != nil {
|
|
||||||
utils.Warn("CRMAssignment: skipping non-numeric GEO member", "name", loc.Name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var profile models.MilerProfile
|
|
||||||
if err := db.DB.Where("userid = ?", milerUserID).First(&profile).Error; err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if profile.Availabilitystatus != constants.MilerAvailable {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var activeCount int64
|
|
||||||
db.DB.Model(&models.BookingAssignment{}).
|
|
||||||
Where("mileruserid = ? AND assignmentstatus IN ?", milerUserID, []string{
|
|
||||||
constants.AssignmentAssigned,
|
|
||||||
constants.AssignmentAccepted,
|
|
||||||
}).
|
|
||||||
Count(&activeCount)
|
|
||||||
|
|
||||||
if activeCount >= maxActive {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
score := loc.Dist*1.0 + float64(activeCount)*2.0 - profile.Rating*0.5
|
|
||||||
|
|
||||||
if score < bestScore {
|
|
||||||
bestScore = score
|
|
||||||
best = &milerCandidate{
|
|
||||||
profile: profile,
|
|
||||||
distanceKm: loc.Dist,
|
|
||||||
activeBookings: activeCount,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return best, best != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// commitAssignment writes the BookingAssignment row, updates the booking and the
|
// commitAssignment writes the BookingAssignment row, updates the booking and the
|
||||||
// miler's availability status in a single transaction, then publishes to NATS.
|
// miler's availability status in a single transaction, then publishes to NATS.
|
||||||
func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate) error {
|
func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate, agentDecisionID *uint64) error {
|
||||||
milerUserID := candidate.profile.Userid
|
milerUserID := candidate.profile.Userid
|
||||||
|
|
||||||
tx := db.DB.Begin()
|
tx := db.DB.Begin()
|
||||||
@@ -201,6 +151,7 @@ func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate)
|
|||||||
Mileruserid: milerUserID,
|
Mileruserid: milerUserID,
|
||||||
Assignmentstatus: constants.AssignmentAssigned,
|
Assignmentstatus: constants.AssignmentAssigned,
|
||||||
Assignedat: time.Now(),
|
Assignedat: time.Now(),
|
||||||
|
AgentDecisionID: agentDecisionID,
|
||||||
}
|
}
|
||||||
if err := tx.Create(&assignment).Error; err != nil {
|
if err := tx.Create(&assignment).Error; err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
@@ -231,7 +182,7 @@ func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate)
|
|||||||
"miler_id", milerUserID,
|
"miler_id", milerUserID,
|
||||||
"distance_km", candidate.distanceKm,
|
"distance_km", candidate.distanceKm,
|
||||||
"active_bookings", candidate.activeBookings,
|
"active_bookings", candidate.activeBookings,
|
||||||
"score", candidate.distanceKm*1.0+float64(candidate.activeBookings)*2.0-candidate.profile.Rating*0.5,
|
"agent_decision_id", agentDecisionID,
|
||||||
)
|
)
|
||||||
|
|
||||||
publishAssignment(booking, milerUserID)
|
publishAssignment(booking, milerUserID)
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func tryCustomerAssign(bookingID int) (bool, error) {
|
|||||||
return false, fmt.Errorf("booking %d has no pickup coordinates", bookingID)
|
return false, fmt.Errorf("booking %d has no pickup coordinates", bookingID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1 — Find the best nearby miler (shared GEO logic from crm_assignment.go).
|
// Step 1 — Find the best nearby miler via AI decision engine (GEORADIUS unchanged).
|
||||||
nearby, err := queryNearbyMilers(booking.Pickuplatitude, booking.Pickuplongitude)
|
nearby, err := queryNearbyMilers(booking.Pickuplatitude, booking.Pickuplongitude)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.Warn("B2CAssignment: GEO query failed", "booking_id", bookingID, "error", err)
|
utils.Warn("B2CAssignment: GEO query failed", "booking_id", bookingID, "error", err)
|
||||||
@@ -92,7 +92,7 @@ func tryCustomerAssign(bookingID int) (bool, error) {
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
miler, found := pickBestMiler(nearby)
|
miler, agentDecisionID, found := selectMilerWithAI(&booking, nearby)
|
||||||
if !found {
|
if !found {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -113,11 +113,11 @@ func tryCustomerAssign(bookingID int) (bool, error) {
|
|||||||
provider = providerResult{company: "Doormile"}
|
provider = providerResult{company: "Doormile"}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6 — ETA based on miler-to-pickup distance.
|
// Step 3 — ETA based on miler-to-pickup distance.
|
||||||
etaMinutes := calculateETA(miler.distanceKm)
|
etaMinutes := calculateETA(miler.distanceKm)
|
||||||
|
|
||||||
// Steps 3–5 — Commit to DB and publish to NATS.
|
// Steps 4–6 — Commit to DB and publish to NATS.
|
||||||
if err := commitCustomerAssignment(&booking, miler, provider, etaMinutes); err != nil {
|
if err := commitCustomerAssignment(&booking, miler, provider, etaMinutes, agentDecisionID); err != nil {
|
||||||
return false, fmt.Errorf("commit: %w", err)
|
return false, fmt.Errorf("commit: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +203,7 @@ func commitCustomerAssignment(
|
|||||||
miler *milerCandidate,
|
miler *milerCandidate,
|
||||||
provider providerResult,
|
provider providerResult,
|
||||||
etaMinutes float64,
|
etaMinutes float64,
|
||||||
|
agentDecisionID *uint64,
|
||||||
) error {
|
) error {
|
||||||
milerUserID := miler.profile.Userid
|
milerUserID := miler.profile.Userid
|
||||||
|
|
||||||
@@ -213,6 +214,7 @@ func commitCustomerAssignment(
|
|||||||
Mileruserid: milerUserID,
|
Mileruserid: milerUserID,
|
||||||
Assignmentstatus: constants.AssignmentAssigned,
|
Assignmentstatus: constants.AssignmentAssigned,
|
||||||
Assignedat: time.Now(),
|
Assignedat: time.Now(),
|
||||||
|
AgentDecisionID: agentDecisionID,
|
||||||
}
|
}
|
||||||
if err := tx.Create(&ba).Error; err != nil {
|
if err := tx.Create(&ba).Error; err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
@@ -249,6 +251,7 @@ func commitCustomerAssignment(
|
|||||||
"estimated_price", provider.estimatedPrice,
|
"estimated_price", provider.estimatedPrice,
|
||||||
"eta_minutes", etaMinutes,
|
"eta_minutes", etaMinutes,
|
||||||
"miler_distance_km", miler.distanceKm,
|
"miler_distance_km", miler.distanceKm,
|
||||||
|
"agent_decision_id", agentDecisionID,
|
||||||
)
|
)
|
||||||
|
|
||||||
publishCustomerAssignment(booking, milerUserID, provider, etaMinutes)
|
publishCustomerAssignment(booking, milerUserID, provider, etaMinutes)
|
||||||
|
|||||||
@@ -45,6 +45,50 @@ func AuthMiddleware(cfg *config.Config) fiber.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HubStaffAuth validates the JWT and requires role 6 (hub staff), then
|
||||||
|
// exposes hubid via c.Locals so hub handlers can scope all queries to it.
|
||||||
|
func HubStaffAuth(cfg *config.Config) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
authHeader := c.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||||
|
"success": false,
|
||||||
|
"message": "authorization header is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(authHeader, " ")
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||||
|
"success": false,
|
||||||
|
"message": "authorization header must be in format: Bearer <token>",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := utils.ParseToken(parts[1], cfg.JWTSecret)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||||
|
"success": false,
|
||||||
|
"message": "invalid or expired token",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims.RoleID != 6 {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||||
|
"success": false,
|
||||||
|
"message": "access restricted to hub staff",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Locals("userid", claims.UserID)
|
||||||
|
c.Locals("email", claims.Email)
|
||||||
|
c.Locals("roleid", claims.RoleID)
|
||||||
|
c.Locals("hubid", claims.HubID)
|
||||||
|
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func RoleCheckMiddleware(allowedRoles ...int) fiber.Handler {
|
func RoleCheckMiddleware(allowedRoles ...int) fiber.Handler {
|
||||||
return func(c *fiber.Ctx) error {
|
return func(c *fiber.Ctx) error {
|
||||||
roleID, ok := c.Locals("roleid").(int)
|
roleID, ok := c.Locals("roleid").(int)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func Migrate(db *gorm.DB) error {
|
|||||||
&models.CarrierPricing{},
|
&models.CarrierPricing{},
|
||||||
&models.DoormilePricing{},
|
&models.DoormilePricing{},
|
||||||
&models.AgentDecision{},
|
&models.AgentDecision{},
|
||||||
|
&models.HubStaffAccount{},
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -62,5 +63,16 @@ func Migrate(db *gorm.DB) error {
|
|||||||
utils.Info("✅ ivfflat index on context_embedding ready")
|
utils.Info("✅ ivfflat index on context_embedding ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tripsheets.status CHECK constraint predates this codebase (not derived
|
||||||
|
// from any gorm tag) and only allowed Draft/Dispatched/Arrived/Cancelled.
|
||||||
|
// Hub batches need an intermediate "Ready" stage before dispatch.
|
||||||
|
db.Exec(`ALTER TABLE tripsheets DROP CONSTRAINT IF EXISTS tripsheets_status_check`)
|
||||||
|
if res := db.Exec(`ALTER TABLE tripsheets ADD CONSTRAINT tripsheets_status_check
|
||||||
|
CHECK (status::text = ANY (ARRAY['Draft','Ready','Dispatched','Arrived','Cancelled']::text[]))`); res.Error != nil {
|
||||||
|
utils.Error("❌ Failed to widen tripsheets_status_check constraint", "error", res.Error)
|
||||||
|
} else {
|
||||||
|
utils.Info("✅ tripsheets_status_check constraint includes Ready")
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ type Consignment struct {
|
|||||||
Returninitiatedat *time.Time `json:"returninitiatedat" gorm:"column:returninitiatedat"`
|
Returninitiatedat *time.Time `json:"returninitiatedat" gorm:"column:returninitiatedat"`
|
||||||
Returndeliveredat *time.Time `json:"returndeliveredat" gorm:"column:returndeliveredat"`
|
Returndeliveredat *time.Time `json:"returndeliveredat" gorm:"column:returndeliveredat"`
|
||||||
Parentconsignmentid *int `json:"parentconsignmentid" gorm:"column:parentconsignmentid"`
|
Parentconsignmentid *int `json:"parentconsignmentid" gorm:"column:parentconsignmentid"`
|
||||||
|
Condition string `json:"condition" gorm:"column:condition;size:50"` // recorded at hub inbound scan: Good, Damaged, etc.
|
||||||
|
Shelf string `json:"shelf" gorm:"column:shelf;size:50"` // hub storage location assigned at inbound scan
|
||||||
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||||
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
||||||
Createdby int `json:"createdby" gorm:"column:createdby"`
|
Createdby int `json:"createdby" gorm:"column:createdby"`
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ type BookingAssignment struct {
|
|||||||
Acceptedat *time.Time `json:"acceptedat" gorm:"column:acceptedat"`
|
Acceptedat *time.Time `json:"acceptedat" gorm:"column:acceptedat"`
|
||||||
Completedat *time.Time `json:"completedat" gorm:"column:completedat"`
|
Completedat *time.Time `json:"completedat" gorm:"column:completedat"`
|
||||||
Remarks string `json:"remarks" gorm:"column:remarks"`
|
Remarks string `json:"remarks" gorm:"column:remarks"`
|
||||||
|
AgentDecisionID *uint64 `json:"agent_decision_id,omitempty" gorm:"column:agent_decision_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (BookingAssignment) TableName() string {
|
func (BookingAssignment) TableName() string {
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ type Tripsheet struct {
|
|||||||
Driveruserid *int `json:"driveruserid" gorm:"column:driveruserid"`
|
Driveruserid *int `json:"driveruserid" gorm:"column:driveruserid"`
|
||||||
Dispatchtime *time.Time `json:"dispatchtime" gorm:"column:dispatchtime"`
|
Dispatchtime *time.Time `json:"dispatchtime" gorm:"column:dispatchtime"`
|
||||||
Arrivaltime *time.Time `json:"arrivaltime" gorm:"column:arrivaltime"`
|
Arrivaltime *time.Time `json:"arrivaltime" gorm:"column:arrivaltime"`
|
||||||
Status string `json:"status" gorm:"column:status;default:Draft"` // Draft, Dispatched, Arrived, Cancelled
|
Status string `json:"status" gorm:"column:status;default:Draft"` // Draft, Ready, Dispatched, Arrived, Cancelled
|
||||||
|
Batchlabel string `json:"batchlabel" gorm:"column:batchlabel;size:100"`
|
||||||
|
Batchkind string `json:"batchkind" gorm:"column:batchkind;size:20"` // local, transfer
|
||||||
|
Destinationlabel string `json:"destinationlabel" gorm:"column:destinationlabel;size:200"`
|
||||||
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||||
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
||||||
Createdby int `json:"createdby" gorm:"column:createdby"`
|
Createdby int `json:"createdby" gorm:"column:createdby"`
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type Hub struct {
|
|||||||
Latitude float64 `json:"latitude" gorm:"column:latitude"`
|
Latitude float64 `json:"latitude" gorm:"column:latitude"`
|
||||||
Longitude float64 `json:"longitude" gorm:"column:longitude"`
|
Longitude float64 `json:"longitude" gorm:"column:longitude"`
|
||||||
Pincode string `json:"pincode" gorm:"column:pincode"`
|
Pincode string `json:"pincode" gorm:"column:pincode"`
|
||||||
|
Capacity int `json:"capacity" gorm:"column:capacity;default:50"`
|
||||||
Status string `json:"status" gorm:"column:status;default:Active"` // Active, InActive
|
Status string `json:"status" gorm:"column:status;default:Active"` // Active, InActive
|
||||||
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||||
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
||||||
@@ -90,6 +91,7 @@ type MilerProfile struct {
|
|||||||
Phone string `json:"phone" gorm:"column:phone;not null"`
|
Phone string `json:"phone" gorm:"column:phone;not null"`
|
||||||
Profilephotourl string `json:"profilephotourl" gorm:"column:profilephotourl"`
|
Profilephotourl string `json:"profilephotourl" gorm:"column:profilephotourl"`
|
||||||
Vehicleid *int `json:"vehicleid" gorm:"column:vehicleid"`
|
Vehicleid *int `json:"vehicleid" gorm:"column:vehicleid"`
|
||||||
|
Hubid *int `json:"hubid" gorm:"column:hubid"`
|
||||||
Defaultvehicletype string `json:"defaultvehicletype" gorm:"column:defaultvehicletype"`
|
Defaultvehicletype string `json:"defaultvehicletype" gorm:"column:defaultvehicletype"`
|
||||||
Currentlatitude float64 `json:"currentlatitude" gorm:"column:currentlatitude"`
|
Currentlatitude float64 `json:"currentlatitude" gorm:"column:currentlatitude"`
|
||||||
Currentlongitude float64 `json:"currentlongitude" gorm:"column:currentlongitude"`
|
Currentlongitude float64 `json:"currentlongitude" gorm:"column:currentlongitude"`
|
||||||
@@ -171,3 +173,20 @@ type TenantLocation struct {
|
|||||||
func (TenantLocation) TableName() string {
|
func (TenantLocation) TableName() string {
|
||||||
return "tenantlocations"
|
return "tenantlocations"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HubStaffAccount struct {
|
||||||
|
Hubstaffaccountid int `json:"hubstaffaccountid" gorm:"primaryKey;column:hubstaffaccountid"`
|
||||||
|
Hubid int `json:"hubid" gorm:"column:hubid;index;not null"`
|
||||||
|
Email string `json:"email" gorm:"column:email;unique;not null"`
|
||||||
|
Passwordhash string `json:"-" gorm:"column:passwordhash;not null"`
|
||||||
|
Displayname string `json:"displayname" gorm:"column:displayname"`
|
||||||
|
Roleid int `json:"roleid" gorm:"column:roleid;default:6"`
|
||||||
|
Isactive bool `json:"isactive" gorm:"column:isactive;default:true"`
|
||||||
|
Tenantid *int `json:"tenantid" gorm:"column:tenantid;index"` // null = Doormile staff (can manage any hub in their city); set = partner staff scoped to their own hub
|
||||||
|
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||||
|
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (HubStaffAccount) TableName() string {
|
||||||
|
return "hubstaffaccounts"
|
||||||
|
}
|
||||||
|
|||||||
@@ -237,6 +237,28 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
adminAuth.Get("/exceptions/:id", controllers.GetExceptionDetails)
|
adminAuth.Get("/exceptions/:id", controllers.GetExceptionDetails)
|
||||||
adminAuth.Put("/exceptions/:id/status", controllers.ResolveException)
|
adminAuth.Put("/exceptions/:id/status", controllers.ResolveException)
|
||||||
|
|
||||||
|
// --------------------
|
||||||
|
// HUB CONSOLE APIS
|
||||||
|
// --------------------
|
||||||
|
hub := api.Group("/hub")
|
||||||
|
hub.Post("/login", controllers.HubStaffLogin(cfg))
|
||||||
|
|
||||||
|
// Authenticated Hub Console routes (role 6)
|
||||||
|
hubAuth := hub.Use(middlewares.HubStaffAuth(cfg))
|
||||||
|
hubAuth.Get("/dashboard", controllers.GetHubDashboardStats)
|
||||||
|
hubAuth.Get("/bookings/unassigned", controllers.GetHubUnassignedBookings)
|
||||||
|
hubAuth.Get("/inbound/today", controllers.GetHubInboundToday)
|
||||||
|
hubAuth.Post("/bookings/:id/inbound", controllers.CreateInboundScan)
|
||||||
|
hubAuth.Get("/batches", controllers.GetHubBatches)
|
||||||
|
hubAuth.Post("/batches", controllers.CreateHubBatch)
|
||||||
|
hubAuth.Patch("/batches/:id/status", controllers.UpdateBatchStatus)
|
||||||
|
hubAuth.Get("/milers", controllers.GetHubMilers)
|
||||||
|
|
||||||
|
// Hub management — enforced Doormile-staff-only inside each handler
|
||||||
|
hubAuth.Post("/staff", controllers.CreateHubStaffAccount)
|
||||||
|
hubAuth.Get("/hubs", controllers.GetHubsInCity)
|
||||||
|
hubAuth.Post("/hubs", controllers.CreateCityHub)
|
||||||
|
|
||||||
// Redis active user caching utilities
|
// Redis active user caching utilities
|
||||||
redisUsers := api.Group("/utils/users/redis")
|
redisUsers := api.Group("/utils/users/redis")
|
||||||
redisUsers.Post("/", controllers.CreateUserRedis)
|
redisUsers.Post("/", controllers.CreateUserRedis)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Claims struct {
|
|||||||
RoleID int `json:"roleid"`
|
RoleID int `json:"roleid"`
|
||||||
TenantID int `json:"tenantid"`
|
TenantID int `json:"tenantid"`
|
||||||
ConfigID int `json:"configid,omitempty"`
|
ConfigID int `json:"configid,omitempty"`
|
||||||
|
HubID int `json:"hubid,omitempty"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +46,26 @@ func GenerateToken(userID int, email string, roleID int, tenantID int, configID
|
|||||||
return token.SignedString([]byte(secret))
|
return token.SignedString([]byte(secret))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenerateHubStaffToken issues a JWT for hub console staff (role 6), carrying
|
||||||
|
// HubID instead of TenantID/ConfigID so hub handlers can scope queries via
|
||||||
|
// c.Locals("hubid") without colliding with the Miler role (5).
|
||||||
|
func GenerateHubStaffToken(userID int, email string, hubID int, secret string) (string, error) {
|
||||||
|
expirationTime := time.Now().Add(24 * time.Hour)
|
||||||
|
claims := &Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Email: email,
|
||||||
|
RoleID: 6,
|
||||||
|
HubID: hubID,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(secret))
|
||||||
|
}
|
||||||
|
|
||||||
func ParseToken(tokenString string, secret string) (*Claims, error) {
|
func ParseToken(tokenString string, secret string) (*Claims, error) {
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
return []byte(secret), nil
|
return []byte(secret), nil
|
||||||
|
|||||||
Reference in New Issue
Block a user