Assignment decided who carried a booking but never what order to run several of them in -- the one capability jupiter had that Doormile did not. It turns out we already own the solver: routes.workolik.com is a live in-house Route Optimization API backed by Valhalla road-network routing, not the paid third party I had assumed. This is a client for it, not a solver. internal/routing posts a rider's active stops and writes back step, previouskms, cumulativekms and ETA onto BookingAssignment. HubBatchAssign calls it after committing a batch, which is exactly the case it exists for: a rider used to walk away with several bookings and no order to run them in. GetMilerAssignments now returns sequenced stops in step order, falling back to newest-first for anything unsequenced. Contract discovered by probing the live service -- the OpenAPI schema types the body as a bare object array, so the field names are not documented anywhere. They are pickuplat/pickuplong/deliverylat/ deliverylong, NOT pickuplatitude/deliverylatitude. Sending the wrong names does not fail: it returns HTTP 200 with every coordinate defaulted to "0.0", no reordering and all distances zero. That trap is recorded in a comment so the next person does not lose an afternoon to it. Numeric fields come back inconsistently typed -- previouskms as a number, actualkms and eta as strings, some decimal -- so they are decoded loosely and coerced, with tests pinning the coercion. Steps for deliveryids we did not send are discarded rather than written, so an echoed or stale id cannot reorder another rider's work. Sequencing is best-effort throughout and runs after assignments commit. The optimizer is a separate service over the network; it being down must leave bookings assigned but unordered, never undo the batch. Step 0 means "not sequenced", not "first". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2317 lines
74 KiB
Go
2317 lines
74 KiB
Go
package controllers
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"doormile/config"
|
||
"doormile/constants"
|
||
"doormile/db"
|
||
"doormile/dto"
|
||
"doormile/internal/assignment"
|
||
"doormile/internal/routing"
|
||
"doormile/models"
|
||
"doormile/utils"
|
||
|
||
"github.com/gofiber/fiber/v2"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// 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.
|
||
// todayMidnight is the start of the current day as the database records it —
|
||
// see utils.DBNow. Using the container's own clock here dropped every row
|
||
// created after noon IST out of "today so far".
|
||
func todayMidnight() time.Time {
|
||
return utils.DBToday()
|
||
}
|
||
|
||
// parseHubDateRange parses optional from/to (YYYY-MM-DD) query params shared
|
||
// by the hub console's date-range-scoped endpoints (dashboard, inbound,
|
||
// bookings), defaulting to "today so far" when both are omitted. Mirrors
|
||
// GetHubReport's range semantics: to is inclusive of the whole day.
|
||
func parseHubDateRange(c *fiber.Ctx) (time.Time, time.Time, error) {
|
||
fromStr := c.Query("from")
|
||
toStr := c.Query("to")
|
||
|
||
if fromStr == "" && toStr == "" {
|
||
return todayMidnight(), utils.DBNow(), nil
|
||
}
|
||
if fromStr == "" || toStr == "" {
|
||
return time.Time{}, time.Time{}, fmt.Errorf("both from and to query params are required (YYYY-MM-DD)")
|
||
}
|
||
|
||
// Parsed as UTC, not time.Local: stored timestamps are bare wall-clock
|
||
// digits, so the bounds must be too — otherwise the window silently shifts
|
||
// with whatever timezone the container happens to run in.
|
||
from, err := time.ParseInLocation("2006-01-02", fromStr, time.UTC)
|
||
if err != nil {
|
||
return time.Time{}, time.Time{}, fmt.Errorf("invalid from date, expected YYYY-MM-DD")
|
||
}
|
||
toDate, err := time.ParseInLocation("2006-01-02", toStr, time.UTC)
|
||
if err != nil {
|
||
return time.Time{}, time.Time{}, fmt.Errorf("invalid to date, expected YYYY-MM-DD")
|
||
}
|
||
to := toDate.Add(24*time.Hour - time.Nanosecond)
|
||
if to.Before(from) {
|
||
return time.Time{}, time.Time{}, fmt.Errorf("to date must not be before from date")
|
||
}
|
||
return from, to, nil
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// scopeBookingsToOwnTenant restricts a bookings query to the requesting hub
|
||
// staff's own tenant when they're partner staff (HubStaffAccount.Tenantid set)
|
||
// — without this, a partner tenant's own hub staff could see every other
|
||
// tenant's bookings passing through the same hub, which is exactly the kind
|
||
// of cross-client leak a multi-tenant flow can't have. Doormile staff
|
||
// (Tenantid nil) are unrestricted, same as everywhere else in this file.
|
||
// Bookings created before Tenantid existed (nil) are only visible to
|
||
// Doormile staff, never to partner staff, since they can't be proven to
|
||
// belong to that partner.
|
||
func scopeBookingsToOwnTenant(c *fiber.Ctx, query *gorm.DB) *gorm.DB {
|
||
staff, err := getCurrentHubStaff(c)
|
||
if err != nil || isDoormileStaff(staff) {
|
||
return query
|
||
}
|
||
return query.Where("tenantid = ?", *staff.Tenantid)
|
||
}
|
||
|
||
func GetHubDashboardStats(c *fiber.Ctx) error {
|
||
hubID := c.Locals("hubid").(int)
|
||
prefix := hubPincodePrefix(hubID)
|
||
|
||
from, to, err := parseHubDateRange(c)
|
||
if err != nil {
|
||
return utils.BadRequest(c, err.Error())
|
||
}
|
||
|
||
var parcelsReceivedToday int64
|
||
db.DB.Model(&models.Consignment{}).
|
||
Where("currenthubid = ? AND status = ? AND updatedat BETWEEN ? AND ?", hubID, constants.ConsignmentInwardedAtHub, from, to).
|
||
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 BETWEEN ? AND ?", hubID, from, to).
|
||
Count(&batchesSentToday)
|
||
|
||
var exceptions int64
|
||
db.DB.Model(&models.ConsignmentException{}).
|
||
Where("hubid = ? AND createdat BETWEEN ? AND ?", hubID, from, to).
|
||
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+"%")
|
||
}
|
||
query = scopeBookingsToOwnTenant(c, query)
|
||
|
||
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)))
|
||
}
|
||
|
||
// hubBookingDisplayStatus normalizes a booking's raw status into the small
|
||
// vocabulary the hub console's history views key off: "pending" (needs a
|
||
// miler), "cancelled", or a handled state ("assigned", "picked_up",
|
||
// "delivered").
|
||
func hubBookingDisplayStatus(status string, hasMiler bool) string {
|
||
if status == constants.BookingCancelled {
|
||
return "cancelled"
|
||
}
|
||
if !hasMiler {
|
||
return "pending"
|
||
}
|
||
switch status {
|
||
case constants.BookingPickedUp:
|
||
return "picked_up"
|
||
case constants.BookingConvertedConsignment:
|
||
return "delivered"
|
||
default:
|
||
return "assigned"
|
||
}
|
||
}
|
||
|
||
// GetHubBookingsRange returns all pickup requests created at this hub in
|
||
// [from, to] (both YYYY-MM-DD, inclusive), each with its current assignment
|
||
// status and miler. Historical superset of GetHubUnassignedBookings, which
|
||
// stays as the live "needs a miler now" view.
|
||
func GetHubBookingsRange(c *fiber.Ctx) error {
|
||
hubID := c.Locals("hubid").(int)
|
||
prefix := hubPincodePrefix(hubID)
|
||
|
||
from, to, err := parseHubDateRange(c)
|
||
if err != nil {
|
||
return utils.BadRequest(c, err.Error())
|
||
}
|
||
|
||
query := db.DB.Preload("Parcels").Where("createdat BETWEEN ? AND ?", from, to)
|
||
if prefix != "" {
|
||
query = query.Where("pickuppincode LIKE ?", prefix+"%")
|
||
}
|
||
query = scopeBookingsToOwnTenant(c, query)
|
||
|
||
var bookings []models.PickupBooking
|
||
if err := query.Order("createdat DESC").Find(&bookings).Error; err != nil {
|
||
return utils.Internal(c, "failed to fetch bookings")
|
||
}
|
||
|
||
response := make([]fiber.Map, 0, len(bookings))
|
||
for _, b := range bookings {
|
||
var customer models.AppCustomer
|
||
db.DB.Where("appcustomerid = ?", b.Appcustomerid).First(&customer)
|
||
|
||
hasMiler := b.Assignedmileruserid != nil
|
||
var milerName interface{}
|
||
if hasMiler {
|
||
var mp models.MilerProfile
|
||
if db.DB.Where("userid = ?", *b.Assignedmileruserid).First(&mp).Error == nil {
|
||
milerName = mp.Displayname
|
||
}
|
||
}
|
||
|
||
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,
|
||
"status": hubBookingDisplayStatus(b.Status, hasMiler),
|
||
"milername": milerName,
|
||
"created_at": b.Createdat,
|
||
})
|
||
}
|
||
|
||
return utils.List(c, response, int64(len(response)))
|
||
}
|
||
|
||
// renderInboundConsignment builds the hub console's inbound-parcel row,
|
||
// shared by GetHubInboundToday and GetHubInboundRange.
|
||
func renderInboundConsignment(cs models.Consignment) fiber.Map {
|
||
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
|
||
}
|
||
}
|
||
|
||
return 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,
|
||
"deliverypincode": cs.Deliverypincode,
|
||
"weight": fmt.Sprintf("%.1f kg", cs.Chargeableweight),
|
||
"chargeableweight": cs.Chargeableweight,
|
||
"condition": cs.Condition,
|
||
"shelf": cs.Shelf,
|
||
// temperature: no column exists anywhere yet for this.
|
||
"temperature": "N/A",
|
||
"status": cs.Status,
|
||
"updatedat": cs.Updatedat,
|
||
}
|
||
}
|
||
|
||
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 {
|
||
response = append(response, renderInboundConsignment(cs))
|
||
}
|
||
|
||
return utils.List(c, response, int64(len(response)))
|
||
}
|
||
|
||
// GetHubInboundRange returns parcels received/inbounded at this hub whose
|
||
// inbound timestamp falls in [from, to] (both YYYY-MM-DD, inclusive),
|
||
// defaulting to today when omitted. Historical counterpart to
|
||
// GetHubInboundToday, which stays as the live "since midnight" view.
|
||
func GetHubInboundRange(c *fiber.Ctx) error {
|
||
hubID := c.Locals("hubid").(int)
|
||
|
||
from, to, err := parseHubDateRange(c)
|
||
if err != nil {
|
||
return utils.BadRequest(c, err.Error())
|
||
}
|
||
|
||
var consignments []models.Consignment
|
||
if err := db.DB.Where("currenthubid = ? AND status = ? AND updatedat BETWEEN ? AND ?", hubID, constants.ConsignmentInwardedAtHub, from, to).
|
||
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 {
|
||
response = append(response, renderInboundConsignment(cs))
|
||
}
|
||
|
||
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
|
||
// --------------------
|
||
|
||
// GetHubBatches lists this hub's outgoing batches. With no from/to it
|
||
// returns every batch (unchanged default); passing from/to (YYYY-MM-DD,
|
||
// inclusive) scopes it to batches created in that range.
|
||
func GetHubBatches(c *fiber.Ctx) error {
|
||
hubID := c.Locals("hubid").(int)
|
||
|
||
query := db.DB.Where("sourcehubid = ? AND deletedat IS NULL", hubID)
|
||
if c.Query("from") != "" || c.Query("to") != "" {
|
||
from, to, err := parseHubDateRange(c)
|
||
if err != nil {
|
||
return utils.BadRequest(c, err.Error())
|
||
}
|
||
query = query.Where("createdat BETWEEN ? AND ?", from, to)
|
||
}
|
||
|
||
var tripsheets []models.Tripsheet
|
||
if err := query.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",
|
||
})
|
||
}
|
||
}
|
||
|
||
// defaultBatchAssignCapPerRider caps how many bookings one rider can receive
|
||
// in a single HubBatchAssign run, so the greedy pass doesn't pile every
|
||
// pending pickup onto whichever rider happens to be closest to the first one.
|
||
const defaultBatchAssignCapPerRider = 5
|
||
|
||
// batchRiderCandidate is a rider available for HubBatchAssign to consider,
|
||
// tracked with a per-run assigned count so the cap can be enforced without a
|
||
// DB round trip per booking.
|
||
type batchRiderCandidate struct {
|
||
userid int
|
||
lat, lon float64
|
||
assigned int
|
||
}
|
||
|
||
// HubBatchAssign is a greedy nearest-available-rider batch dispatcher: for
|
||
// every pending, unassigned booking at this hub (oldest first), it assigns
|
||
// the closest rider who hasn't hit defaultBatchAssignCapPerRider yet in this
|
||
// run, using the exact same transactional AssignMilerToBooking path
|
||
// HubAssignMiler and AdminAssignMiler already use for one-at-a-time
|
||
// assignment. This is a nearest-neighbor heuristic, not full multi-stop
|
||
// route optimization (no route sequencing within a rider's stops, no
|
||
// distance-matrix solver) — it answers "who's closest and free," not
|
||
// "what's the optimal round for every rider." Good enough to clear a queue
|
||
// of pending pickups without a human clicking through them one by one;
|
||
// upgrade later if stop-sequencing quality becomes the actual bottleneck.
|
||
func HubBatchAssign(c *fiber.Ctx) error {
|
||
hubID := c.Locals("hubid").(int)
|
||
staffID := c.Locals("userid").(int)
|
||
prefix := hubPincodePrefix(hubID)
|
||
|
||
var req struct {
|
||
Bookingids []int `json:"bookingids"`
|
||
MaxPerRider int `json:"max_per_rider"`
|
||
}
|
||
if err := c.BodyParser(&req); err != nil {
|
||
return utils.BadRequest(c, "invalid request body")
|
||
}
|
||
capPerRider := req.MaxPerRider
|
||
if capPerRider <= 0 {
|
||
capPerRider = defaultBatchAssignCapPerRider
|
||
}
|
||
|
||
bookingQuery := db.DB.Where("assignedmileruserid IS NULL AND status = ?", constants.BookingPendingPickup)
|
||
if len(req.Bookingids) > 0 {
|
||
bookingQuery = bookingQuery.Where("bookingid IN ?", req.Bookingids)
|
||
} else if prefix != "" {
|
||
bookingQuery = bookingQuery.Where("pickuppincode LIKE ?", prefix+"%")
|
||
}
|
||
bookingQuery = scopeBookingsToOwnTenant(c, bookingQuery)
|
||
|
||
var bookings []models.PickupBooking
|
||
if err := bookingQuery.Order("createdat ASC").Find(&bookings).Error; err != nil {
|
||
return utils.Internal(c, "failed to fetch pending bookings")
|
||
}
|
||
if len(bookings) == 0 {
|
||
return utils.OK(c, fiber.Map{"assigned": 0, "skipped": 0, "results": []fiber.Map{}})
|
||
}
|
||
|
||
var riderProfiles []models.MilerProfile
|
||
if err := db.DB.Where("hubid = ? AND availabilitystatus = ?", hubID, constants.MilerAvailable).
|
||
Find(&riderProfiles).Error; err != nil {
|
||
return utils.Internal(c, "failed to fetch available riders")
|
||
}
|
||
|
||
candidates := make([]*batchRiderCandidate, 0, len(riderProfiles))
|
||
for _, mp := range riderProfiles {
|
||
candidates = append(candidates, &batchRiderCandidate{
|
||
userid: mp.Userid, lat: mp.Currentlatitude, lon: mp.Currentlongitude,
|
||
})
|
||
}
|
||
|
||
results := make([]fiber.Map, 0, len(bookings))
|
||
assignedCount, skippedCount := 0, 0
|
||
|
||
for _, b := range bookings {
|
||
var nearest *batchRiderCandidate
|
||
nearestDist := math.MaxFloat64
|
||
|
||
for _, cand := range candidates {
|
||
if cand.assigned >= capPerRider {
|
||
continue
|
||
}
|
||
d := haversineKM(b.Pickuplatitude, b.Pickuplongitude, cand.lat, cand.lon)
|
||
if d < nearestDist {
|
||
nearestDist = d
|
||
nearest = cand
|
||
}
|
||
}
|
||
|
||
if nearest == nil {
|
||
results = append(results, fiber.Map{
|
||
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
|
||
"assigned": false, "reason": "no available rider under capacity",
|
||
})
|
||
skippedCount++
|
||
continue
|
||
}
|
||
|
||
if _, err := AssignMilerToBooking(b.Bookingid, nearest.userid, &staffID); err != nil {
|
||
results = append(results, fiber.Map{
|
||
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
|
||
"assigned": false, "reason": err.Error(),
|
||
})
|
||
skippedCount++
|
||
continue
|
||
}
|
||
|
||
nearest.assigned++
|
||
results = append(results, fiber.Map{
|
||
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
|
||
"assigned": true, "mileruserid": nearest.userid, "distance_km": nearestDist,
|
||
})
|
||
assignedCount++
|
||
}
|
||
|
||
// Batch assignment is exactly the case stop-ordering exists for: a rider
|
||
// walks out of here with several bookings and, until now, no indication of
|
||
// what order to run them in. Sequence each rider that actually got work.
|
||
//
|
||
// Best-effort and deliberately after the assignments are committed: the
|
||
// optimizer is a separate service over the network, and it failing must
|
||
// leave the bookings assigned rather than undoing the batch.
|
||
sequenced := 0
|
||
for _, r := range ridersAssigned(results) {
|
||
if _, err := routing.SequenceMilerStops(r); err != nil {
|
||
utils.Warn("HubBatchAssign: stop sequencing failed",
|
||
"miler_userid", r, "error", err)
|
||
continue
|
||
}
|
||
sequenced++
|
||
}
|
||
|
||
return utils.OK(c, fiber.Map{
|
||
"assigned": assignedCount,
|
||
"skipped": skippedCount,
|
||
"riderssequenced": sequenced,
|
||
"results": results,
|
||
})
|
||
}
|
||
|
||
// ridersAssigned pulls the distinct miler user ids out of a batch result set,
|
||
// so each rider is sequenced once rather than once per booking they received.
|
||
func ridersAssigned(results []fiber.Map) []int {
|
||
seen := make(map[int]struct{})
|
||
var ids []int
|
||
for _, r := range results {
|
||
ok, _ := r["assigned"].(bool)
|
||
if !ok {
|
||
continue
|
||
}
|
||
id, isInt := r["mileruserid"].(int)
|
||
if !isInt {
|
||
continue
|
||
}
|
||
if _, dup := seen[id]; dup {
|
||
continue
|
||
}
|
||
seen[id] = struct{}{}
|
||
ids = append(ids, id)
|
||
}
|
||
return ids
|
||
}
|
||
|
||
// --------------------
|
||
// 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,
|
||
})
|
||
}
|