1973 lines
62 KiB
Go
1973 lines
62 KiB
Go
package controllers
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"doormile/config"
|
||
"doormile/constants"
|
||
"doormile/db"
|
||
"doormile/dto"
|
||
"doormile/internal/assignment"
|
||
"doormile/models"
|
||
"doormile/utils"
|
||
|
||
"github.com/gofiber/fiber/v2"
|
||
)
|
||
|
||
// hubAutoAssignTimeout bounds how long HubAutoAssign waits synchronously for
|
||
// TryAssignOnce (GEOSEARCH + AI decision + commit) before returning 202 and
|
||
// letting the assignment finish in the background.
|
||
const hubAutoAssignTimeout = 8 * time.Second
|
||
|
||
// assignableBookingStatuses gates both HubAssignMiler and HubAutoAssign.
|
||
// Pending_Assignment / Assignment_Failed aren't statuses this codebase
|
||
// currently sets anywhere (only Pending_Pickup is real today) but are kept
|
||
// here so the gate doesn't need touching if those states get introduced later.
|
||
var assignableBookingStatuses = map[string]bool{
|
||
constants.BookingPendingPickup: true,
|
||
"Pending_Assignment": true,
|
||
"Assignment_Failed": true,
|
||
}
|
||
|
||
// defaultJourneyMinutes is the assumed total truck transit time until real
|
||
// GPS telemetry (EMQX) is wired up; used to interpolate in-transit position.
|
||
const defaultJourneyMinutes = 480 // 8 hours
|
||
|
||
// zoneNames maps a handful of known pincodes in our 4 operating cities to
|
||
// human-readable locality names. Unmapped pincodes fall back to "Zone <code>".
|
||
var zoneNames = map[string]string{
|
||
"641001": "Gandhipuram",
|
||
"641002": "RS Puram",
|
||
"641004": "Peelamedu",
|
||
"641012": "Jupiter Nagar",
|
||
"641035": "Saravanampatti",
|
||
"500003": "Secunderabad",
|
||
"500032": "Financial District",
|
||
"500034": "Banjara Hills",
|
||
"500081": "Gachibowli",
|
||
"560034": "Koramangala",
|
||
"560038": "Indiranagar",
|
||
"560066": "Whitefield",
|
||
"560100": "Electronic City",
|
||
"600017": "T Nagar",
|
||
"600032": "Guindy",
|
||
"600040": "Anna Nagar",
|
||
"600042": "Velachery",
|
||
}
|
||
|
||
func zoneName(pincode string) string {
|
||
if name, ok := zoneNames[pincode]; ok {
|
||
return name
|
||
}
|
||
return "Zone " + pincode
|
||
}
|
||
|
||
// haversineKM returns the great-circle distance between two lat/lon points in km.
|
||
func haversineKM(lat1, lon1, lat2, lon2 float64) float64 {
|
||
const earthRadiusKM = 6371.0
|
||
toRad := func(deg float64) float64 { return deg * math.Pi / 180 }
|
||
dLat := toRad(lat2 - lat1)
|
||
dLon := toRad(lon2 - lon1)
|
||
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||
math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||
return earthRadiusKM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||
}
|
||
|
||
// humanizeRelativeTime renders a timestamp as "5 min ago" / "2 hrs ago" / "3 days ago".
|
||
func humanizeRelativeTime(t time.Time) string {
|
||
d := time.Since(t)
|
||
switch {
|
||
case d < time.Minute:
|
||
return "just now"
|
||
case d < time.Hour:
|
||
return fmt.Sprintf("%d min ago", int(d.Minutes()))
|
||
case d < 24*time.Hour:
|
||
return fmt.Sprintf("%d hrs ago", int(d.Hours()))
|
||
default:
|
||
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
|
||
// Sorting happens at inbound scan (CreateInboundScan assigns a shelf), so
|
||
// today's inwarded count doubles as "parcels sorted today"; the target is
|
||
// the hub's own declared daily capacity rather than a made-up constant.
|
||
var hub models.Hub
|
||
db.DB.Where("hubid = ?", hubID).First(&hub)
|
||
|
||
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,
|
||
"parcels_sorted": parcelsReceivedToday,
|
||
"sorting_target": hub.Capacity,
|
||
})
|
||
}
|
||
|
||
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")
|
||
}
|
||
|
||
response := make([]fiber.Map, 0, len(consignments))
|
||
for _, cs := range consignments {
|
||
originName := fmt.Sprintf("Direct pickup (%s)", cs.Pickuppincode)
|
||
if cs.Originhubid != nil {
|
||
var oh models.Hub
|
||
if db.DB.Where("hubid = ?", *cs.Originhubid).First(&oh).Error == nil {
|
||
originName = oh.Hubname
|
||
}
|
||
}
|
||
|
||
response = append(response, fiber.Map{
|
||
"consignmentid": cs.Consignmentid,
|
||
"trackingno": cs.Trackingno,
|
||
// sendername is empty: consignments carry no FK back to any
|
||
// customer/booking record (Senderid/Receiverid are never
|
||
// populated anywhere in this codebase).
|
||
"sendername": "",
|
||
"originname": originName,
|
||
// destinationname: consignments store no free-text delivery
|
||
// address, only deliverypincode — used as the best available label.
|
||
"destinationname": cs.Deliverypincode,
|
||
"condition": cs.Condition,
|
||
"shelf": cs.Shelf,
|
||
// temperature: no column exists anywhere yet for this.
|
||
"temperature": "N/A",
|
||
"status": cs.Status,
|
||
"updatedat": cs.Updatedat,
|
||
})
|
||
}
|
||
|
||
return utils.List(c, response, int64(len(response)))
|
||
}
|
||
|
||
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, enriched
|
||
// with computed operational stats per miler.
|
||
func GetHubMilers(c *fiber.Ctx) error {
|
||
hubID := c.Locals("hubid").(int)
|
||
midnight := todayMidnight()
|
||
weekAgo := time.Now().AddDate(0, 0, -7)
|
||
|
||
var profiles []models.MilerProfile
|
||
if err := db.DB.Where("hubid = ?", hubID).Find(&profiles).Error; err != nil {
|
||
return utils.Internal(c, "failed to fetch milers")
|
||
}
|
||
|
||
response := make([]fiber.Map, 0, len(profiles))
|
||
for _, mp := range profiles {
|
||
var zones []string
|
||
db.DB.Model(&models.BookingAssignment{}).
|
||
Joins("JOIN pickupbookings pb ON pb.bookingid = bookingassignments.bookingid").
|
||
Where("bookingassignments.mileruserid = ? AND bookingassignments.assignedat >= ?", mp.Userid, weekAgo).
|
||
Distinct("pb.pickuppincode").
|
||
Pluck("pb.pickuppincode", &zones)
|
||
if zones == nil {
|
||
zones = []string{}
|
||
}
|
||
|
||
// assignmentstatus (BookingAssignment's real status column) only has
|
||
// Assigned/Accepted/Rejected/Reassigned/Completed/Cancelled — treated
|
||
// Assigned+Accepted as "still active load".
|
||
var assignedLoad int64
|
||
db.DB.Model(&models.BookingAssignment{}).
|
||
Where("mileruserid = ? AND assignmentstatus IN ?", mp.Userid,
|
||
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
|
||
Count(&assignedLoad)
|
||
|
||
// Pending_Pickup lives on pickupbookings.status, not on the
|
||
// assignment itself, so this counts via a join.
|
||
var pickupsPending int64
|
||
db.DB.Model(&models.BookingAssignment{}).
|
||
Joins("JOIN pickupbookings pb ON pb.bookingid = bookingassignments.bookingid").
|
||
Where("bookingassignments.mileruserid = ? AND pb.status = ?", mp.Userid, constants.BookingPendingPickup).
|
||
Count(&pickupsPending)
|
||
|
||
// bookingpayments has no mileruserid column; collectedbyuserid is the
|
||
// app user id of whoever collected it, which for miler collections is
|
||
// the miler's own userid.
|
||
var codCollected float64
|
||
db.DB.Model(&models.BookingPayment{}).
|
||
Where("collectedbyuserid = ? AND paymentstatus = ? AND createdat >= ?", mp.Userid, constants.PaymentStatusPaid, midnight).
|
||
Select("COALESCE(SUM(amount), 0)").Scan(&codCollected)
|
||
|
||
var codPending float64
|
||
db.DB.Model(&models.BookingPayment{}).
|
||
Where("collectedbyuserid = ? AND paymentstatus = ?", mp.Userid, constants.PaymentStatusPending).
|
||
Select("COALESCE(SUM(amount), 0)").Scan(&codPending)
|
||
|
||
var firstAssignment models.BookingAssignment
|
||
var checkinAt *time.Time
|
||
if db.DB.Where("mileruserid = ? AND assignedat >= ?", mp.Userid, midnight).
|
||
Order("assignedat ASC").First(&firstAssignment).Error == nil {
|
||
checkinAt = &firstAssignment.Assignedat
|
||
}
|
||
|
||
hoursActive := 0.0
|
||
if checkinAt != nil {
|
||
hoursActive = time.Since(*checkinAt).Hours()
|
||
}
|
||
|
||
var vehicleNo string
|
||
if mp.Vehicleid != nil {
|
||
var v models.Vehicle
|
||
if db.DB.Where("vehicleid = ?", *mp.Vehicleid).First(&v).Error == nil {
|
||
vehicleNo = v.Vehicleno
|
||
}
|
||
}
|
||
|
||
response = append(response, fiber.Map{
|
||
"milerprofileid": mp.Milerprofileid,
|
||
"userid": mp.Userid,
|
||
"displayname": mp.Displayname,
|
||
"phone": mp.Phone,
|
||
"vehicleid": mp.Vehicleid,
|
||
"hubid": mp.Hubid,
|
||
"defaultvehicletype": mp.Defaultvehicletype,
|
||
"vehicleno": vehicleNo,
|
||
"currentlatitude": mp.Currentlatitude,
|
||
"currentlongitude": mp.Currentlongitude,
|
||
"currentpincode": mp.Currentpincode,
|
||
"availabilitystatus": mp.Availabilitystatus,
|
||
"rating": mp.Rating,
|
||
"totalcompletedpickups": mp.Totalcompletedpickups,
|
||
"totalcancelledpickups": mp.Totalcancelledpickups,
|
||
"zones": zones,
|
||
"assignedload": assignedLoad,
|
||
"capacity": 30, // hardcoded until vehicle capacity is modelled
|
||
"pickupspending": pickupsPending,
|
||
"codcollected": codCollected,
|
||
"codpending": codPending,
|
||
"checkinat": checkinAt,
|
||
"hoursactive": hoursActive,
|
||
"isverified": mp.Devicetoken != "",
|
||
})
|
||
}
|
||
|
||
return utils.List(c, response, int64(len(response)))
|
||
}
|
||
|
||
// --------------------
|
||
// HUB MESSAGES / CHAT
|
||
// --------------------
|
||
|
||
// ensureHubConversations lazily creates a HubConversation for every miler
|
||
// currently assigned to this hub that doesn't already have one, so the
|
||
// message list always reflects the hub's real roster without needing a
|
||
// separate provisioning endpoint.
|
||
func ensureHubConversations(hubID int) {
|
||
var profiles []models.MilerProfile
|
||
if err := db.DB.Where("hubid = ?", hubID).Find(&profiles).Error; err != nil {
|
||
return
|
||
}
|
||
|
||
for _, mp := range profiles {
|
||
var existing models.HubConversation
|
||
if db.DB.Where("hubid = ? AND mileruserid = ?", hubID, mp.Userid).First(&existing).Error == nil {
|
||
continue
|
||
}
|
||
userid := mp.Userid
|
||
conv := models.HubConversation{
|
||
Hubid: hubID,
|
||
Mileruserid: &userid,
|
||
Participantname: mp.Displayname,
|
||
Participantrole: "Miler",
|
||
}
|
||
db.DB.Create(&conv)
|
||
}
|
||
}
|
||
|
||
// GetHubMessages returns the hub's conversation list, ordered by most recent
|
||
// activity, with each conversation's last message and unread count.
|
||
func GetHubMessages(c *fiber.Ctx) error {
|
||
hubID := c.Locals("hubid").(int)
|
||
ensureHubConversations(hubID)
|
||
|
||
var conversations []models.HubConversation
|
||
if err := db.DB.Where("hubid = ?", hubID).Order("updatedat DESC").Find(&conversations).Error; err != nil {
|
||
return utils.Internal(c, "failed to fetch conversations")
|
||
}
|
||
|
||
response := make([]fiber.Map, 0, len(conversations))
|
||
for _, conv := range conversations {
|
||
var lastMsg models.HubMessage
|
||
lastMessageText := ""
|
||
lastTime := conv.Createdat
|
||
if db.DB.Where("hubconversationid = ?", conv.Hubconversationid).
|
||
Order("createdat DESC").First(&lastMsg).Error == nil {
|
||
lastMessageText = lastMsg.Messagetext
|
||
lastTime = lastMsg.Createdat
|
||
}
|
||
|
||
var unread int64
|
||
db.DB.Model(&models.HubMessage{}).
|
||
Where("hubconversationid = ? AND sender = 'them' AND isread = false", conv.Hubconversationid).
|
||
Count(&unread)
|
||
|
||
response = append(response, fiber.Map{
|
||
"id": conv.Hubconversationid,
|
||
"name": conv.Participantname,
|
||
"lastmessage": lastMessageText,
|
||
"time": humanizeRelativeTime(lastTime),
|
||
"unread": unread,
|
||
})
|
||
}
|
||
|
||
return utils.List(c, response, int64(len(response)))
|
||
}
|
||
|
||
// GetHubMessageThread returns one conversation's full message history.
|
||
func GetHubMessageThread(c *fiber.Ctx) error {
|
||
hubID := c.Locals("hubid").(int)
|
||
id, err := strconv.Atoi(c.Params("id"))
|
||
if err != nil {
|
||
return utils.BadRequest(c, "invalid conversation id")
|
||
}
|
||
|
||
var conv models.HubConversation
|
||
if err := db.DB.Where("hubconversationid = ? AND hubid = ?", id, hubID).First(&conv).Error; err != nil {
|
||
return utils.NotFound(c, "conversation not found")
|
||
}
|
||
|
||
var messages []models.HubMessage
|
||
db.DB.Where("hubconversationid = ?", id).Order("createdat ASC").Find(&messages)
|
||
|
||
msgResponse := make([]fiber.Map, 0, len(messages))
|
||
for _, m := range messages {
|
||
msgResponse = append(msgResponse, fiber.Map{
|
||
"sender": m.Sender,
|
||
"text": m.Messagetext,
|
||
"time": m.Createdat.Local().Format("3:04 PM"),
|
||
})
|
||
}
|
||
|
||
return utils.OK(c, fiber.Map{
|
||
"id": conv.Hubconversationid,
|
||
"name": conv.Participantname,
|
||
"messages": msgResponse,
|
||
})
|
||
}
|
||
|
||
// SendHubMessage posts a reply from the logged-in hub staff into a conversation.
|
||
func SendHubMessage(c *fiber.Ctx) error {
|
||
hubID := c.Locals("hubid").(int)
|
||
staffID := c.Locals("userid").(int)
|
||
id, err := strconv.Atoi(c.Params("id"))
|
||
if err != nil {
|
||
return utils.BadRequest(c, "invalid conversation id")
|
||
}
|
||
|
||
type SendMessageRequest struct {
|
||
Text string `json:"text"`
|
||
}
|
||
req := new(SendMessageRequest)
|
||
if err := c.BodyParser(req); err != nil {
|
||
return utils.BadRequest(c, "invalid request body")
|
||
}
|
||
if strings.TrimSpace(req.Text) == "" {
|
||
return utils.BadRequest(c, "text is required")
|
||
}
|
||
|
||
var conv models.HubConversation
|
||
if err := db.DB.Where("hubconversationid = ? AND hubid = ?", id, hubID).First(&conv).Error; err != nil {
|
||
return utils.NotFound(c, "conversation not found")
|
||
}
|
||
|
||
message := models.HubMessage{
|
||
Hubconversationid: id,
|
||
Sender: "me",
|
||
Senderstaffid: &staffID,
|
||
Messagetext: req.Text,
|
||
Isread: true,
|
||
}
|
||
if err := db.DB.Create(&message).Error; err != nil {
|
||
return utils.Internal(c, "failed to send message")
|
||
}
|
||
|
||
conv.Updatedat = time.Now()
|
||
db.DB.Save(&conv)
|
||
|
||
return utils.OK(c, fiber.Map{
|
||
"sender": "me",
|
||
"text": message.Messagetext,
|
||
"time": message.Createdat.Local().Format("3:04 PM"),
|
||
})
|
||
}
|
||
|
||
// MarkHubMessagesRead clears the unread flag on every inbound ("them")
|
||
// message in a conversation.
|
||
func MarkHubMessagesRead(c *fiber.Ctx) error {
|
||
hubID := c.Locals("hubid").(int)
|
||
id, err := strconv.Atoi(c.Params("id"))
|
||
if err != nil {
|
||
return utils.BadRequest(c, "invalid conversation id")
|
||
}
|
||
|
||
var conv models.HubConversation
|
||
if err := db.DB.Where("hubconversationid = ? AND hubid = ?", id, hubID).First(&conv).Error; err != nil {
|
||
return utils.NotFound(c, "conversation not found")
|
||
}
|
||
|
||
db.DB.Model(&models.HubMessage{}).
|
||
Where("hubconversationid = ? AND sender = 'them' AND isread = false", id).
|
||
Update("isread", true)
|
||
|
||
return utils.Message(c, "messages marked as read")
|
||
}
|
||
|
||
// --------------------
|
||
// 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,
|
||
"lat": h.Latitude,
|
||
"lon": h.Longitude,
|
||
"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)
|
||
}
|
||
|
||
// --------------------
|
||
// IN-TRANSIT TRACKING & INBOUND VEHICLES
|
||
// --------------------
|
||
|
||
// GetInTransitTripsheets returns tripsheets currently moving to/from this hub,
|
||
// with a linearly-interpolated position along the source→dest line since we
|
||
// don't have real truck GPS yet. Swap in real coordinates later — shape stays.
|
||
func GetInTransitTripsheets(c *fiber.Ctx) error {
|
||
hubID := c.Locals("hubid").(int)
|
||
|
||
var tripsheets []models.Tripsheet
|
||
if err := db.DB.Where("(sourcehubid = ? OR destinationhubid = ?) AND status IN ? AND deletedat IS NULL",
|
||
hubID, hubID, []string{constants.TripsheetDispatched, "In_Transit"}).
|
||
Order("dispatchtime DESC").Find(&tripsheets).Error; err != nil {
|
||
return utils.Internal(c, "failed to fetch in-transit tripsheets")
|
||
}
|
||
|
||
response := make([]fiber.Map, 0, len(tripsheets))
|
||
for _, ts := range tripsheets {
|
||
var source, dest models.Hub
|
||
db.DB.Where("hubid = ?", ts.Sourcehubid).First(&source)
|
||
db.DB.Where("hubid = ?", ts.Destinationhubid).First(&dest)
|
||
|
||
var vehicleNo string
|
||
if ts.Vehicleid != nil {
|
||
var v models.Vehicle
|
||
if db.DB.Where("vehicleid = ?", *ts.Vehicleid).First(&v).Error == nil {
|
||
vehicleNo = v.Vehicleno
|
||
}
|
||
}
|
||
|
||
var itemCount int64
|
||
db.DB.Model(&models.TripsheetItem{}).Where("tripsheetid = ?", ts.Tripsheetid).Count(&itemCount)
|
||
|
||
elapsedMinutes := 0.0
|
||
if ts.Dispatchtime != nil {
|
||
elapsedMinutes = time.Since(*ts.Dispatchtime).Minutes()
|
||
}
|
||
progressPct := int(math.Min(95, (elapsedMinutes/defaultJourneyMinutes)*100))
|
||
if progressPct < 0 {
|
||
progressPct = 0
|
||
}
|
||
etaMinutes := int(defaultJourneyMinutes - elapsedMinutes)
|
||
if etaMinutes < 0 {
|
||
etaMinutes = 0
|
||
}
|
||
|
||
frac := float64(progressPct) / 100.0
|
||
currentLat := source.Latitude + (dest.Latitude-source.Latitude)*frac
|
||
currentLon := source.Longitude + (dest.Longitude-source.Longitude)*frac
|
||
|
||
response = append(response, fiber.Map{
|
||
"tripsheetid": ts.Tripsheetid,
|
||
"tripsheetno": ts.Tripsheetno,
|
||
"label": fmt.Sprintf("%s → %s", source.Hubname, dest.Hubname),
|
||
"originlat": source.Latitude,
|
||
"originlon": source.Longitude,
|
||
"destlat": dest.Latitude,
|
||
"destlon": dest.Longitude,
|
||
"currentlat": currentLat,
|
||
"currentlon": currentLon,
|
||
"status": ts.Status,
|
||
"progresspct": progressPct,
|
||
"vehicleno": vehicleNo,
|
||
"itemcount": itemCount,
|
||
"dispatchtime": ts.Dispatchtime,
|
||
"eta_minutes": etaMinutes,
|
||
})
|
||
}
|
||
|
||
return utils.List(c, response, int64(len(response)))
|
||
}
|
||
|
||
// GetInboundVehicles returns vehicles arriving at or expected at this hub.
|
||
func GetInboundVehicles(c *fiber.Ctx) error {
|
||
hubID := c.Locals("hubid").(int)
|
||
|
||
var tripsheets []models.Tripsheet
|
||
if err := db.DB.Where("destinationhubid = ? AND status IN ? AND deletedat IS NULL",
|
||
hubID, []string{constants.TripsheetDispatched, "In_Transit", constants.TripsheetArrived}).
|
||
Order("dispatchtime DESC").Limit(20).Find(&tripsheets).Error; err != nil {
|
||
return utils.Internal(c, "failed to fetch inbound vehicles")
|
||
}
|
||
|
||
response := make([]fiber.Map, 0, len(tripsheets))
|
||
for _, ts := range tripsheets {
|
||
var source models.Hub
|
||
db.DB.Where("hubid = ?", ts.Sourcehubid).First(&source)
|
||
|
||
var vehicleNo, vehicleType string
|
||
if ts.Vehicleid != nil {
|
||
var v models.Vehicle
|
||
if db.DB.Where("vehicleid = ?", *ts.Vehicleid).First(&v).Error == nil {
|
||
vehicleNo = v.Vehicleno
|
||
vehicleType = v.Vehicletype
|
||
}
|
||
}
|
||
|
||
var totalItems, unloadedItems int64
|
||
db.DB.Model(&models.TripsheetItem{}).Where("tripsheetid = ?", ts.Tripsheetid).Count(&totalItems)
|
||
|
||
status := "On the way"
|
||
unloadedPct := 0
|
||
if ts.Status == constants.TripsheetArrived {
|
||
status = "Unloading"
|
||
db.DB.Model(&models.TripsheetItem{}).
|
||
Where("tripsheetid = ? AND scanstatus = ?", ts.Tripsheetid, constants.ScanUnloaded).
|
||
Count(&unloadedItems)
|
||
if totalItems > 0 {
|
||
unloadedPct = int(float64(unloadedItems) / float64(totalItems) * 100)
|
||
}
|
||
}
|
||
|
||
eta := "Arrived"
|
||
if ts.Status != constants.TripsheetArrived && ts.Dispatchtime != nil {
|
||
remaining := defaultJourneyMinutes - int(time.Since(*ts.Dispatchtime).Minutes())
|
||
if remaining < 0 {
|
||
remaining = 0
|
||
}
|
||
eta = fmt.Sprintf("%d hrs %d min", remaining/60, remaining%60)
|
||
}
|
||
|
||
response = append(response, fiber.Map{
|
||
"vehicleno": vehicleNo,
|
||
"vehicletype": vehicleType,
|
||
"origin": source.Hubname,
|
||
"tripsheetid": ts.Tripsheetid,
|
||
"status": status,
|
||
"eta": eta,
|
||
"unloadedpct": unloadedPct,
|
||
"itemcount": totalItems,
|
||
})
|
||
}
|
||
|
||
return utils.List(c, response, int64(len(response)))
|
||
}
|
||
|
||
// --------------------
|
||
// HUB ACTIVITY FEED
|
||
// --------------------
|
||
|
||
type activityEntry struct {
|
||
Time time.Time `gorm:"column:time"`
|
||
Type string `gorm:"column:type"`
|
||
Text string `gorm:"column:text"`
|
||
}
|
||
|
||
// GetHubActivity aggregates real events from 4 sources (inbound scans,
|
||
// dispatches, exceptions, miler assignments) into one timeline.
|
||
func GetHubActivity(c *fiber.Ctx) error {
|
||
hubID := c.Locals("hubid").(int)
|
||
limit, err := strconv.Atoi(c.Query("limit", "10"))
|
||
if err != nil || limit <= 0 {
|
||
limit = 10
|
||
}
|
||
midnight := todayMidnight()
|
||
|
||
// Source 4 (miler assignments) is scoped by mp.hubid via a join — the
|
||
// ticket's version had no hub filter at all, which would leak every
|
||
// hub's assignment activity into every other hub's feed.
|
||
query := `
|
||
(SELECT c.updatedat AS time, 'inbound' AS type,
|
||
('Received parcel ' || c.trackingno || ' from ' || COALESCE(oh.hubname, 'Direct Pickup')) AS text
|
||
FROM consignments c
|
||
LEFT JOIN hubs oh ON c.originhubid = oh.hubid
|
||
WHERE c.currenthubid = ? AND c.status = ? AND c.updatedat > ?)
|
||
UNION ALL
|
||
(SELECT t.dispatchtime AS time, 'dispatch' AS type,
|
||
('Dispatched batch ' || t.tripsheetno || ' to ' || COALESCE(t.destinationlabel, '')) AS text
|
||
FROM tripsheets t
|
||
WHERE t.sourcehubid = ? AND t.dispatchtime > ?)
|
||
UNION ALL
|
||
(SELECT ce.createdat AS time, 'exception' AS type,
|
||
('Exception raised: ' || ce.exceptiontype || ' on ' || c.trackingno) AS text
|
||
FROM consignmentexceptions ce
|
||
JOIN consignments c ON ce.consignmentid = c.consignmentid
|
||
WHERE ce.hubid = ? AND ce.createdat > ?)
|
||
UNION ALL
|
||
(SELECT ba.assignedat AS time, 'sorting' AS type,
|
||
('Assigned booking #' || ba.bookingid || ' to miler ' || mp.displayname) AS text
|
||
FROM bookingassignments ba
|
||
JOIN milerprofiles mp ON ba.mileruserid = mp.userid
|
||
WHERE mp.hubid = ? AND ba.assignedat > ?)
|
||
ORDER BY time DESC
|
||
LIMIT ?
|
||
`
|
||
|
||
var entries []activityEntry
|
||
if err := db.DB.Raw(query,
|
||
hubID, constants.ConsignmentInwardedAtHub, midnight,
|
||
hubID, midnight,
|
||
hubID, midnight,
|
||
hubID, midnight,
|
||
limit,
|
||
).Scan(&entries).Error; err != nil {
|
||
return utils.Internal(c, "failed to fetch hub activity")
|
||
}
|
||
|
||
response := make([]fiber.Map, 0, len(entries))
|
||
for _, e := range entries {
|
||
response = append(response, fiber.Map{
|
||
"time": e.Time,
|
||
"type": e.Type,
|
||
"text": e.Text,
|
||
})
|
||
}
|
||
|
||
return utils.List(c, response, int64(len(response)))
|
||
}
|
||
|
||
// --------------------
|
||
// DELIVERY ZONES
|
||
// --------------------
|
||
|
||
// GetHubZones returns delivery zone breakdown for this hub's milers, falling
|
||
// back to static known zones for the hub's city when there's too little
|
||
// assignment data yet (new hub, few bookings).
|
||
func GetHubZones(c *fiber.Ctx) error {
|
||
hubID := c.Locals("hubid").(int)
|
||
|
||
type zoneRow struct {
|
||
Zone string `gorm:"column:zone"`
|
||
Milers int64 `gorm:"column:milers"`
|
||
Parcels int64 `gorm:"column:parcels"`
|
||
Status string `gorm:"column:status"`
|
||
}
|
||
|
||
// ba.pickuppincode / ba.status don't exist on bookingassignments —
|
||
// pincode lives on pickupbookings (joined here) and the real status
|
||
// column is assignmentstatus.
|
||
query := `
|
||
SELECT pb.pickuppincode AS zone,
|
||
COUNT(DISTINCT ba.mileruserid) AS milers,
|
||
COUNT(ba.bookingassignmentid) AS parcels,
|
||
CASE WHEN COUNT(DISTINCT ba.mileruserid) = 0 THEN 'Need Milers' ELSE 'Active' END AS status
|
||
FROM bookingassignments ba
|
||
JOIN milerprofiles mp ON ba.mileruserid = mp.userid
|
||
JOIN pickupbookings pb ON pb.bookingid = ba.bookingid
|
||
WHERE mp.hubid = ? AND ba.assignmentstatus IN ?
|
||
GROUP BY pb.pickuppincode
|
||
ORDER BY parcels DESC
|
||
LIMIT 10
|
||
`
|
||
|
||
var rows []zoneRow
|
||
db.DB.Raw(query, hubID, []string{constants.AssignmentAssigned, constants.AssignmentAccepted}).Scan(&rows)
|
||
|
||
seen := make(map[string]bool)
|
||
response := make([]fiber.Map, 0, len(rows))
|
||
for _, r := range rows {
|
||
seen[r.Zone] = true
|
||
response = append(response, fiber.Map{
|
||
"zone": r.Zone,
|
||
"zonename": zoneName(r.Zone),
|
||
"parcels": r.Parcels,
|
||
"milers": r.Milers,
|
||
"status": r.Status,
|
||
})
|
||
}
|
||
|
||
if len(response) < 3 {
|
||
prefix := hubPincodePrefix(hubID)
|
||
for pincode := range zoneNames {
|
||
if strings.HasPrefix(pincode, prefix) && !seen[pincode] {
|
||
response = append(response, fiber.Map{
|
||
"zone": pincode,
|
||
"zonename": zoneName(pincode),
|
||
"parcels": 0,
|
||
"milers": 0,
|
||
"status": "Need Milers",
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
return utils.List(c, response, int64(len(response)))
|
||
}
|
||
|
||
// --------------------
|
||
// NOTIFICATIONS (synthetic — no dedicated table yet)
|
||
// --------------------
|
||
|
||
type notificationEntry struct {
|
||
Title string
|
||
Type string
|
||
Time time.Time
|
||
}
|
||
|
||
// GetHubNotifications generates notifications from real events across 4
|
||
// sources since there's no dedicated notifications table yet.
|
||
func GetHubNotifications(c *fiber.Ctx) error {
|
||
hubID := c.Locals("hubid").(int)
|
||
midnight := todayMidnight()
|
||
last24h := time.Now().Add(-24 * time.Hour)
|
||
|
||
var notifications []notificationEntry
|
||
|
||
var exceptions []models.ConsignmentException
|
||
db.DB.Where("hubid = ? AND createdat > ?", hubID, last24h).Find(&exceptions)
|
||
for _, e := range exceptions {
|
||
notifications = append(notifications, notificationEntry{
|
||
Title: "Exception: " + e.Exceptiontype,
|
||
Type: "exception",
|
||
Time: e.Createdat,
|
||
})
|
||
}
|
||
|
||
var inbound []models.Tripsheet
|
||
db.DB.Where("destinationhubid = ? AND status IN ?", hubID, []string{constants.TripsheetDispatched, "In_Transit"}).Find(&inbound)
|
||
for _, ts := range inbound {
|
||
var source models.Hub
|
||
db.DB.Where("hubid = ?", ts.Sourcehubid).First(&source)
|
||
t := ts.Updatedat
|
||
if ts.Dispatchtime != nil {
|
||
t = *ts.Dispatchtime
|
||
}
|
||
notifications = append(notifications, notificationEntry{
|
||
Title: "Truck arriving from " + source.Hubname,
|
||
Type: "inbound",
|
||
Time: t,
|
||
})
|
||
}
|
||
|
||
var offlineMilers []models.MilerProfile
|
||
db.DB.Where("hubid = ? AND availabilitystatus = ?", hubID, constants.MilerOffline).Find(&offlineMilers)
|
||
for _, mp := range offlineMilers {
|
||
var hadActivityToday int64
|
||
db.DB.Model(&models.BookingAssignment{}).Where("mileruserid = ? AND assignedat >= ?", mp.Userid, midnight).Count(&hadActivityToday)
|
||
if hadActivityToday > 0 {
|
||
notifications = append(notifications, notificationEntry{
|
||
Title: "Miler " + mp.Displayname + " went offline",
|
||
Type: "warning",
|
||
Time: mp.Updatedat,
|
||
})
|
||
}
|
||
}
|
||
|
||
prefix := hubPincodePrefix(hubID)
|
||
thirtyMinAgo := time.Now().Add(-30 * time.Minute)
|
||
var stalePending []models.PickupBooking
|
||
staleQuery := db.DB.Where("assignedmileruserid IS NULL AND status = ? AND createdat < ?", constants.BookingPendingPickup, thirtyMinAgo)
|
||
if prefix != "" {
|
||
staleQuery = staleQuery.Where("pickuppincode LIKE ?", prefix+"%")
|
||
}
|
||
staleQuery.Find(&stalePending)
|
||
for _, b := range stalePending {
|
||
notifications = append(notifications, notificationEntry{
|
||
Title: "Pickup waiting 30+ min",
|
||
Type: "alert",
|
||
Time: b.Createdat,
|
||
})
|
||
}
|
||
|
||
sort.Slice(notifications, func(i, j int) bool { return notifications[i].Time.After(notifications[j].Time) })
|
||
if len(notifications) > 20 {
|
||
notifications = notifications[:20]
|
||
}
|
||
|
||
response := make([]fiber.Map, 0, len(notifications))
|
||
for i, n := range notifications {
|
||
response = append(response, fiber.Map{
|
||
"id": i + 1,
|
||
"title": n.Title,
|
||
"type": n.Type,
|
||
"time": humanizeRelativeTime(n.Time),
|
||
"read": false,
|
||
})
|
||
}
|
||
|
||
return utils.List(c, response, int64(len(response)))
|
||
}
|
||
|
||
// MarkNotificationRead is a stub until a notifications table with read-state
|
||
// exists — always succeeds without persisting anything.
|
||
func MarkNotificationRead(c *fiber.Ctx) error {
|
||
return c.JSON(fiber.Map{"success": true})
|
||
}
|
||
|
||
// --------------------
|
||
// PARCEL ROUTING (SCAN)
|
||
// --------------------
|
||
|
||
// resolveNextHop compares the delivery pincode's city prefix against the
|
||
// requesting hub's own city prefix to decide local delivery vs. transfer.
|
||
func resolveNextHop(destPrefix, myPrefix string) string {
|
||
if destPrefix == "" || destPrefix == myPrefix {
|
||
return "Local delivery"
|
||
}
|
||
var hub models.Hub
|
||
if db.DB.Where("pincode LIKE ?", destPrefix+"%").First(&hub).Error == nil {
|
||
return "Transfer to " + hub.Hubname
|
||
}
|
||
return "Transfer to Regional Hub"
|
||
}
|
||
|
||
func recommendShelfForRouting(condition string, isColdChain bool, nextHop string) string {
|
||
switch {
|
||
case strings.Contains(condition, "Damaged"):
|
||
return "Exception Area"
|
||
case isColdChain:
|
||
return "Zone C (Cold Room)"
|
||
case nextHop == "Local delivery":
|
||
return "Zone B"
|
||
default:
|
||
return "Zone A"
|
||
}
|
||
}
|
||
|
||
// GetRoutingInfo scans a parcel and returns its sort destination, checking
|
||
// both consignments.trackingno and pickupbookings.bookingno (a booking not
|
||
// yet converted to a consignment has no trackingno of its own).
|
||
func GetRoutingInfo(c *fiber.Ctx) error {
|
||
hubID := c.Locals("hubid").(int)
|
||
trackingNo := c.Params("trackingno")
|
||
myPrefix := hubPincodePrefix(hubID)
|
||
|
||
var consignment models.Consignment
|
||
if db.DB.Where("trackingno = ?", trackingNo).First(&consignment).Error == nil {
|
||
destPrefix := ""
|
||
if len(consignment.Deliverypincode) >= 3 {
|
||
destPrefix = consignment.Deliverypincode[:3]
|
||
}
|
||
nextHop := resolveNextHop(destPrefix, myPrefix)
|
||
|
||
shelf := consignment.Shelf
|
||
if shelf == "" {
|
||
shelf = recommendShelfForRouting(consignment.Condition, false, nextHop)
|
||
}
|
||
|
||
return utils.OK(c, fiber.Map{
|
||
"trackingno": consignment.Trackingno,
|
||
"destination": consignment.Deliverypincode,
|
||
"recommendedshelf": shelf,
|
||
"nexthop": nextHop,
|
||
"condition": consignment.Condition,
|
||
"iscoldchain": false,
|
||
"weight": fmt.Sprintf("%.1f kg", consignment.Chargeableweight),
|
||
// no FK from consignments back to a customer/booking record
|
||
"customername": "",
|
||
"bookingid": nil,
|
||
})
|
||
}
|
||
|
||
var booking models.PickupBooking
|
||
found := db.DB.Where("bookingno = ?", trackingNo).First(&booking).Error == nil
|
||
if !found {
|
||
if id, err := strconv.Atoi(trackingNo); err == nil {
|
||
found = db.DB.Where("bookingid = ?", id).First(&booking).Error == nil
|
||
}
|
||
}
|
||
if !found {
|
||
return utils.NotFound(c, "tracking number not found")
|
||
}
|
||
|
||
destPrefix := ""
|
||
if len(booking.Deliverypincode) >= 3 {
|
||
destPrefix = booking.Deliverypincode[:3]
|
||
}
|
||
nextHop := resolveNextHop(destPrefix, myPrefix)
|
||
shelf := recommendShelfForRouting("", false, nextHop)
|
||
|
||
var customer models.AppCustomer
|
||
db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer)
|
||
customerName := strings.TrimSpace(customer.Firstname + " " + customer.Lastname)
|
||
|
||
weight := "N/A"
|
||
var parcel models.BookingParcel
|
||
if db.DB.Where("bookingid = ?", booking.Bookingid).First(&parcel).Error == nil {
|
||
weight = fmt.Sprintf("%.1f kg", parcel.Weight)
|
||
}
|
||
|
||
return utils.OK(c, fiber.Map{
|
||
"trackingno": booking.Bookingno,
|
||
"destination": fmt.Sprintf("%s, %s", booking.Deliveryaddress, booking.Deliverypincode),
|
||
"recommendedshelf": shelf,
|
||
"nexthop": nextHop,
|
||
"condition": "Pending Scan",
|
||
"iscoldchain": false,
|
||
"weight": weight,
|
||
"customername": customerName,
|
||
"bookingid": booking.Bookingid,
|
||
})
|
||
}
|
||
|
||
// --------------------
|
||
// RIDER ROUTES
|
||
// --------------------
|
||
|
||
// buildMilerRoute computes today's planned stops for one miler. eta_minutes
|
||
// for pending stops is a simple 15-min-per-stop placeholder until a real
|
||
// routing engine is wired up — same spirit as the in-transit interpolation.
|
||
func buildMilerRoute(mp models.MilerProfile) fiber.Map {
|
||
var assignments []models.BookingAssignment
|
||
db.DB.Where("mileruserid = ? AND assignedat >= ?", mp.Userid, todayMidnight()).
|
||
Order("assignedat ASC").Find(&assignments)
|
||
|
||
stops := make([]fiber.Map, 0, len(assignments))
|
||
var coords [][2]float64
|
||
completedCount := 0
|
||
pendingEtaMinutes := 0
|
||
|
||
for i, a := range assignments {
|
||
var booking models.PickupBooking
|
||
if db.DB.Where("bookingid = ?", a.Bookingid).First(&booking).Error != nil {
|
||
continue
|
||
}
|
||
|
||
itemCount := 0
|
||
var parcel models.BookingParcel
|
||
if db.DB.Where("bookingid = ?", a.Bookingid).First(&parcel).Error == nil {
|
||
itemCount = 1
|
||
}
|
||
|
||
status := "pending"
|
||
switch booking.Status {
|
||
case constants.BookingPickedUp, constants.BookingConvertedConsignment:
|
||
status = "completed"
|
||
case constants.BookingPickupScheduled:
|
||
status = "in_progress"
|
||
}
|
||
|
||
etaMinutes := 0
|
||
if status == "completed" {
|
||
completedCount++
|
||
} else {
|
||
pendingEtaMinutes += 15
|
||
etaMinutes = pendingEtaMinutes
|
||
}
|
||
|
||
coords = append(coords, [2]float64{booking.Pickuplatitude, booking.Pickuplongitude})
|
||
|
||
stop := fiber.Map{
|
||
"seq": i + 1,
|
||
"address": booking.Pickupaddress,
|
||
"lat": booking.Pickuplatitude,
|
||
"lon": booking.Pickuplongitude,
|
||
"items": itemCount,
|
||
"bookingid": booking.Bookingid,
|
||
"status": status,
|
||
"eta_minutes": etaMinutes,
|
||
}
|
||
|
||
// Optional enrichment: only set when real data backs it, so the
|
||
// frontend's "hide when absent" handling never sees a fake zero.
|
||
if itemCount == 1 {
|
||
stop["weight"] = fmt.Sprintf("%.1f kg", parcel.Weight)
|
||
}
|
||
var payment models.BookingPayment
|
||
if db.DB.Where("bookingid = ?", a.Bookingid).First(&payment).Error == nil && payment.Amount > 0 {
|
||
stop["cod"] = payment.Amount
|
||
}
|
||
if booking.Preferredpickupfrom != nil && booking.Preferredpickupto != nil {
|
||
stop["timeslot"] = fmt.Sprintf("%s–%s",
|
||
booking.Preferredpickupfrom.Format("15"), booking.Preferredpickupto.Format("15"))
|
||
}
|
||
if booking.Notes != "" {
|
||
stop["instructions"] = booking.Notes
|
||
}
|
||
if n := len(coords); n > 1 {
|
||
leg := haversineKM(coords[n-2][0], coords[n-2][1], coords[n-1][0], coords[n-1][1])
|
||
stop["legdistance_km"] = math.Round(leg*10) / 10
|
||
}
|
||
|
||
stops = append(stops, stop)
|
||
}
|
||
|
||
totalDistance := 0.0
|
||
for i := 1; i < len(coords); i++ {
|
||
totalDistance += haversineKM(coords[i-1][0], coords[i-1][1], coords[i][0], coords[i][1])
|
||
}
|
||
|
||
return fiber.Map{
|
||
"mileruserid": mp.Userid,
|
||
"milername": mp.Displayname,
|
||
"mode": "pickup",
|
||
"totalstops": len(stops),
|
||
"completedstops": completedCount,
|
||
"totaldistance_km": math.Round(totalDistance*10) / 10,
|
||
"stops": stops,
|
||
}
|
||
}
|
||
|
||
// GetMilerRoute returns today's planned stops for a specific miler (:id is
|
||
// the mileruserid, matching bookingassignments.mileruserid).
|
||
func GetMilerRoute(c *fiber.Ctx) error {
|
||
id, err := strconv.Atoi(c.Params("id"))
|
||
if err != nil {
|
||
return utils.BadRequest(c, "invalid miler id")
|
||
}
|
||
|
||
var mp models.MilerProfile
|
||
if err := db.DB.Where("userid = ?", id).First(&mp).Error; err != nil {
|
||
return utils.NotFound(c, "miler not found")
|
||
}
|
||
|
||
return utils.OK(c, buildMilerRoute(mp))
|
||
}
|
||
|
||
// GetAllRiderRoutes returns the same route structure for every miler at this
|
||
// hub today, for the Rider Routes overview page.
|
||
func GetAllRiderRoutes(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")
|
||
}
|
||
|
||
routes := make([]fiber.Map, 0, len(profiles))
|
||
for _, mp := range profiles {
|
||
routes = append(routes, buildMilerRoute(mp))
|
||
}
|
||
|
||
return utils.List(c, routes, int64(len(routes)))
|
||
}
|
||
|
||
// --------------------
|
||
// HUB-SCOPED ASSIGNMENT (manual override + auto-assign)
|
||
// --------------------
|
||
|
||
// HubAssignMiler is the hub console's manual assignment override — hub staff
|
||
// (role 6) can't call POST /admin/bookings/:id/assign-miler (admin-only) or
|
||
// rely on POST /internal/bookings/:id/reassign (requires an already-assigned
|
||
// booking), so this exists for a hub-scoped path onto the same shared
|
||
// AssignMilerToBooking logic AdminAssignMiler uses.
|
||
func HubAssignMiler(c *fiber.Ctx) error {
|
||
hubID := c.Locals("hubid").(int)
|
||
|
||
bookingID, err := strconv.Atoi(c.Params("id"))
|
||
if err != nil {
|
||
return utils.BadRequest(c, "invalid booking id")
|
||
}
|
||
|
||
type AssignRequest struct {
|
||
Mileruserid int `json:"mileruserid"`
|
||
}
|
||
req := new(AssignRequest)
|
||
if err := c.BodyParser(req); err != nil {
|
||
return utils.BadRequest(c, "invalid request body")
|
||
}
|
||
if req.Mileruserid == 0 {
|
||
return utils.BadRequest(c, "mileruserid is required")
|
||
}
|
||
|
||
var booking models.PickupBooking
|
||
if err := db.DB.First(&booking, bookingID).Error; err != nil {
|
||
return utils.NotFound(c, "booking not found")
|
||
}
|
||
|
||
if !assignableBookingStatuses[booking.Status] {
|
||
return utils.BadRequest(c, "booking is not in an assignable state")
|
||
}
|
||
|
||
var miler models.MilerProfile
|
||
if err := db.DB.Where("userid = ? AND hubid = ?", req.Mileruserid, hubID).First(&miler).Error; err != nil {
|
||
return utils.Forbidden(c, "miler does not belong to this hub")
|
||
}
|
||
|
||
// assignedbyuserid carries a real FK to appusers(userid); hub staff
|
||
// accounts live in a separate hubstaffaccounts id space, so passing that
|
||
// id here would violate the constraint (or worse, silently misattribute
|
||
// to whatever appuser happens to share that id). Pass nil instead — the
|
||
// field is nullable and this is the honest option until hub staff get a
|
||
// mirrored appusers row.
|
||
updatedBooking, err := AssignMilerToBooking(bookingID, req.Mileruserid, nil)
|
||
if err != nil {
|
||
return utils.Internal(c, "failed to assign miler")
|
||
}
|
||
|
||
return c.JSON(fiber.Map{
|
||
"success": true,
|
||
"message": "miler assigned",
|
||
"data": fiber.Map{
|
||
"bookingid": updatedBooking.Bookingid,
|
||
"mileruserid": req.Mileruserid,
|
||
"milername": miler.Displayname,
|
||
"status": updatedBooking.Status,
|
||
},
|
||
})
|
||
}
|
||
|
||
// HubAutoAssign triggers the AI assignment engine for one booking that wasn't
|
||
// automatically assigned at creation time. It runs TryAssignOnce (a single
|
||
// GEOSEARCH + AI-decision + commit attempt, no retry loop) in a goroutine and
|
||
// waits up to hubAutoAssignTimeout — long enough for the typical sub-second
|
||
// Redis GEO query plus the AI layer's own 5s HTTP timeout, but bounded so the
|
||
// HTTP handler never hangs indefinitely. If it doesn't finish in time, the
|
||
// goroutine keeps running in the background and the assignment still lands.
|
||
func HubAutoAssign(c *fiber.Ctx) error {
|
||
bookingID, err := strconv.Atoi(c.Params("id"))
|
||
if err != nil {
|
||
return utils.BadRequest(c, "invalid booking id")
|
||
}
|
||
|
||
var booking models.PickupBooking
|
||
if err := db.DB.First(&booking, bookingID).Error; err != nil {
|
||
return utils.NotFound(c, "booking not found")
|
||
}
|
||
|
||
if !assignableBookingStatuses[booking.Status] {
|
||
return utils.BadRequest(c, "booking is not in an assignable state")
|
||
}
|
||
|
||
type outcome struct {
|
||
res assignment.AutoAssignResult
|
||
err error
|
||
}
|
||
resultCh := make(chan outcome, 1)
|
||
|
||
go func() {
|
||
res, err := assignment.TryAssignOnce(bookingID)
|
||
resultCh <- outcome{res, err}
|
||
}()
|
||
|
||
select {
|
||
case out := <-resultCh:
|
||
if out.err != nil {
|
||
return utils.Internal(c, "assignment failed: "+out.err.Error())
|
||
}
|
||
|
||
if !out.res.Assigned {
|
||
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
|
||
"success": false,
|
||
"message": "No eligible miler found in range",
|
||
"data": fiber.Map{
|
||
"searched_radius_km": out.res.SearchedRadiusKm,
|
||
"candidates_found": out.res.CandidatesFound,
|
||
"reasoning": out.res.Reasoning,
|
||
},
|
||
})
|
||
}
|
||
|
||
return c.JSON(fiber.Map{
|
||
"success": true,
|
||
"data": fiber.Map{
|
||
"mileruserid": out.res.MilerUserID,
|
||
"milername": out.res.MilerName,
|
||
"distance_km": out.res.DistanceKm,
|
||
// no confidence field: the AI decision engine's response has
|
||
// no such value anywhere in this pipeline to report honestly.
|
||
"reasoning": out.res.Reasoning,
|
||
},
|
||
})
|
||
|
||
case <-time.After(hubAutoAssignTimeout):
|
||
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{
|
||
"success": true,
|
||
"message": "assignment in progress",
|
||
})
|
||
}
|
||
}
|
||
|
||
// --------------------
|
||
// HUB REPORT EXPORT
|
||
// --------------------
|
||
|
||
// reportActivityLimit caps the activity timeline included in a report so a
|
||
// wide date range can't return an unbounded result set.
|
||
const reportActivityLimit = 500
|
||
|
||
// GetHubReport builds the data backing the dashboard's Export button: a
|
||
// summary plus per-section breakdowns, each filtered to [from, to] (inclusive
|
||
// day range) and scoped to the requesting hub.
|
||
func GetHubReport(c *fiber.Ctx) error {
|
||
hubID := c.Locals("hubid").(int)
|
||
|
||
fromStr := c.Query("from")
|
||
toStr := c.Query("to")
|
||
if fromStr == "" || toStr == "" {
|
||
return utils.BadRequest(c, "from and to query params are required (YYYY-MM-DD)")
|
||
}
|
||
|
||
from, err := time.ParseInLocation("2006-01-02", fromStr, time.Local)
|
||
if err != nil {
|
||
return utils.BadRequest(c, "invalid from date, expected YYYY-MM-DD")
|
||
}
|
||
toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local)
|
||
if err != nil {
|
||
return utils.BadRequest(c, "invalid to date, expected YYYY-MM-DD")
|
||
}
|
||
to := toDate.Add(24*time.Hour - time.Nanosecond)
|
||
if to.Before(from) {
|
||
return utils.BadRequest(c, "to date must not be before from date")
|
||
}
|
||
|
||
// ---- summary ----
|
||
var parcelsReceived int64
|
||
db.DB.Model(&models.ConsignmentHistory{}).
|
||
Where("hubid = ? AND eventstatus = ? AND createdat BETWEEN ? AND ?", hubID, constants.ConsignmentInwardedAtHub, from, to).
|
||
Count(&parcelsReceived)
|
||
|
||
var batchesDispatched int64
|
||
db.DB.Model(&models.Tripsheet{}).
|
||
Where("sourcehubid = ? AND dispatchtime BETWEEN ? AND ? AND deletedat IS NULL", hubID, from, to).
|
||
Count(&batchesDispatched)
|
||
|
||
var ordersAssigned int64
|
||
db.DB.Model(&models.BookingAssignment{}).
|
||
Joins("JOIN milerprofiles mp ON mp.userid = bookingassignments.mileruserid").
|
||
Where("mp.hubid = ? AND bookingassignments.assignedat BETWEEN ? AND ?", hubID, from, to).
|
||
Count(&ordersAssigned)
|
||
|
||
var exceptionsCount int64
|
||
db.DB.Model(&models.ConsignmentException{}).
|
||
Where("hubid = ? AND createdat BETWEEN ? AND ?", hubID, from, to).
|
||
Count(&exceptionsCount)
|
||
|
||
var codCollected float64
|
||
db.DB.Model(&models.BookingPayment{}).
|
||
Joins("JOIN milerprofiles mp ON mp.userid = bookingpayments.collectedbyuserid").
|
||
Where("mp.hubid = ? AND bookingpayments.paymentstatus = ? AND bookingpayments.createdat BETWEEN ? AND ?",
|
||
hubID, constants.PaymentStatusPaid, from, to).
|
||
Select("COALESCE(SUM(bookingpayments.amount), 0)").Scan(&codCollected)
|
||
|
||
// ---- inbound: sourced from consignmenthistory, not consignments.status,
|
||
// so a parcel that has since moved past "Inwarded_at_Hub" still shows up
|
||
// for the day it actually arrived. ----
|
||
var inboundHistory []models.ConsignmentHistory
|
||
db.DB.Where("hubid = ? AND eventstatus = ? AND createdat BETWEEN ? AND ?", hubID, constants.ConsignmentInwardedAtHub, from, to).
|
||
Order("createdat ASC").Find(&inboundHistory)
|
||
|
||
inbound := make([]fiber.Map, 0, len(inboundHistory))
|
||
for _, h := range inboundHistory {
|
||
var cs models.Consignment
|
||
if db.DB.Where("consignmentid = ?", h.Consignmentid).First(&cs).Error != nil {
|
||
continue
|
||
}
|
||
originName := fmt.Sprintf("Direct pickup (%s)", cs.Pickuppincode)
|
||
if cs.Originhubid != nil {
|
||
var oh models.Hub
|
||
if db.DB.Where("hubid = ?", *cs.Originhubid).First(&oh).Error == nil {
|
||
originName = oh.Hubname
|
||
}
|
||
}
|
||
inbound = append(inbound, fiber.Map{
|
||
"date": h.Createdat.Format("2006-01-02"),
|
||
// no FK from consignments back to a customer/booking record
|
||
"bookingid": nil,
|
||
"trackingnumber": cs.Trackingno,
|
||
"sendername": "",
|
||
"origin": originName,
|
||
"destination": cs.Deliverypincode,
|
||
"weight": fmt.Sprintf("%.1f kg", cs.Chargeableweight),
|
||
"condition": cs.Condition,
|
||
"shelf": cs.Shelf,
|
||
"inboundedat": h.Createdat,
|
||
})
|
||
}
|
||
|
||
// ---- dispatch ----
|
||
var tripsheets []models.Tripsheet
|
||
db.DB.Where("sourcehubid = ? AND dispatchtime BETWEEN ? AND ? AND deletedat IS NULL", hubID, from, to).
|
||
Order("dispatchtime ASC").Find(&tripsheets)
|
||
|
||
dispatch := make([]fiber.Map, 0, len(tripsheets))
|
||
for _, ts := range tripsheets {
|
||
var vehicleNo string
|
||
if ts.Vehicleid != nil {
|
||
var v models.Vehicle
|
||
if db.DB.Where("vehicleid = ?", *ts.Vehicleid).First(&v).Error == nil {
|
||
vehicleNo = v.Vehicleno
|
||
}
|
||
}
|
||
var itemCount int64
|
||
db.DB.Model(&models.TripsheetItem{}).Where("tripsheetid = ?", ts.Tripsheetid).Count(&itemCount)
|
||
|
||
date := ""
|
||
if ts.Dispatchtime != nil {
|
||
date = ts.Dispatchtime.Format("2006-01-02")
|
||
}
|
||
|
||
dispatch = append(dispatch, fiber.Map{
|
||
"date": date,
|
||
"batchlabel": ts.Batchlabel,
|
||
"kind": ts.Batchkind,
|
||
"destination": ts.Destinationlabel,
|
||
"vehicle": vehicleNo,
|
||
"itemcount": itemCount,
|
||
"status": ts.Status,
|
||
"dispatchtime": ts.Dispatchtime,
|
||
})
|
||
}
|
||
|
||
// ---- assignments ----
|
||
var assignments []models.BookingAssignment
|
||
db.DB.Joins("JOIN milerprofiles mp ON mp.userid = bookingassignments.mileruserid").
|
||
Where("mp.hubid = ? AND bookingassignments.assignedat BETWEEN ? AND ?", hubID, from, to).
|
||
Order("bookingassignments.assignedat ASC").Find(&assignments)
|
||
|
||
assignmentRows := make([]fiber.Map, 0, len(assignments))
|
||
for _, a := range assignments {
|
||
var booking models.PickupBooking
|
||
if db.DB.Where("bookingid = ?", a.Bookingid).First(&booking).Error != nil {
|
||
continue
|
||
}
|
||
var customer models.AppCustomer
|
||
db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer)
|
||
var miler models.MilerProfile
|
||
db.DB.Where("userid = ?", a.Mileruserid).First(&miler)
|
||
|
||
assignmentRows = append(assignmentRows, fiber.Map{
|
||
"date": a.Assignedat.Format("2006-01-02"),
|
||
"bookingid": a.Bookingid,
|
||
"customer": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
|
||
"milername": miler.Displayname,
|
||
"pickup": zoneName(booking.Pickuppincode),
|
||
"delivery": booking.Deliverycity,
|
||
"status": booking.Status,
|
||
})
|
||
}
|
||
|
||
// ---- exceptions ----
|
||
var exceptions []models.ConsignmentException
|
||
db.DB.Where("hubid = ? AND createdat BETWEEN ? AND ?", hubID, from, to).
|
||
Order("createdat ASC").Find(&exceptions)
|
||
|
||
exceptionRows := make([]fiber.Map, 0, len(exceptions))
|
||
for _, ex := range exceptions {
|
||
var cs models.Consignment
|
||
db.DB.Where("consignmentid = ?", ex.Consignmentid).First(&cs)
|
||
|
||
reason := ex.Description
|
||
if reason == "" {
|
||
reason = ex.Exceptiontype
|
||
}
|
||
|
||
exceptionRows = append(exceptionRows, fiber.Map{
|
||
"date": ex.Createdat.Format("2006-01-02"),
|
||
"trackingnumber": cs.Trackingno,
|
||
"reason": reason,
|
||
"shelf": cs.Shelf,
|
||
})
|
||
}
|
||
|
||
// ---- activity: same 4-source union as GetHubActivity, windowed to the
|
||
// report's date range instead of "today". ----
|
||
activityQuery := `
|
||
(SELECT c.updatedat AS time, 'inbound' AS type,
|
||
('Received parcel ' || c.trackingno || ' from ' || COALESCE(oh.hubname, 'Direct Pickup')) AS text
|
||
FROM consignments c
|
||
LEFT JOIN hubs oh ON c.originhubid = oh.hubid
|
||
WHERE c.currenthubid = ? AND c.status = ? AND c.updatedat BETWEEN ? AND ?)
|
||
UNION ALL
|
||
(SELECT t.dispatchtime AS time, 'dispatch' AS type,
|
||
('Dispatched batch ' || t.tripsheetno || ' to ' || COALESCE(t.destinationlabel, '')) AS text
|
||
FROM tripsheets t
|
||
WHERE t.sourcehubid = ? AND t.dispatchtime BETWEEN ? AND ?)
|
||
UNION ALL
|
||
(SELECT ce.createdat AS time, 'exception' AS type,
|
||
('Exception raised: ' || ce.exceptiontype || ' on ' || c.trackingno) AS text
|
||
FROM consignmentexceptions ce
|
||
JOIN consignments c ON ce.consignmentid = c.consignmentid
|
||
WHERE ce.hubid = ? AND ce.createdat BETWEEN ? AND ?)
|
||
UNION ALL
|
||
(SELECT ba.assignedat AS time, 'sorting' AS type,
|
||
('Assigned booking #' || ba.bookingid || ' to miler ' || mp.displayname) AS text
|
||
FROM bookingassignments ba
|
||
JOIN milerprofiles mp ON ba.mileruserid = mp.userid
|
||
WHERE mp.hubid = ? AND ba.assignedat BETWEEN ? AND ?)
|
||
ORDER BY time ASC
|
||
LIMIT ?
|
||
`
|
||
var activityEntries []activityEntry
|
||
db.DB.Raw(activityQuery,
|
||
hubID, constants.ConsignmentInwardedAtHub, from, to,
|
||
hubID, from, to,
|
||
hubID, from, to,
|
||
hubID, from, to,
|
||
reportActivityLimit,
|
||
).Scan(&activityEntries)
|
||
|
||
activity := make([]fiber.Map, 0, len(activityEntries))
|
||
for _, e := range activityEntries {
|
||
activity = append(activity, fiber.Map{
|
||
"time": e.Time,
|
||
"type": e.Type,
|
||
"text": e.Text,
|
||
})
|
||
}
|
||
|
||
return utils.OK(c, fiber.Map{
|
||
"range": fiber.Map{
|
||
"from": fromStr,
|
||
"to": toStr,
|
||
},
|
||
"summary": fiber.Map{
|
||
"parcels_received": parcelsReceived,
|
||
"batches_dispatched": batchesDispatched,
|
||
"orders_assigned": ordersAssigned,
|
||
"exceptions": exceptionsCount,
|
||
"cod_collected": codCollected,
|
||
},
|
||
"inbound": inbound,
|
||
"dispatch": dispatch,
|
||
"assignments": assignmentRows,
|
||
"exceptions": exceptionRows,
|
||
"activity": activity,
|
||
})
|
||
}
|