Caught by testing the previous commit against production: creating a booking with a resolved site failed with pickupbookings_pickuplocationid_fkey FOREIGN KEY (pickuplocationid) REFERENCES appcustomerlocations(...) pickuplocationid is the *customer's* saved address, a B2C concept. It never referred to the client company's own kitchens or branches. The pre-existing code that validated an incoming pickuplocationid against TenantLocation was wrong on the same point and would have 500'd for any caller that used it — it had simply never been called with a value. Adds tenantlocationid to pickupbookings and consignments (nullable, indexed, additive via AutoMigrate), carried across at pickup, and points the reporting filter, the by_location breakdown and the Unattributed bucket at it. The booking request accepts tenantlocationid, and still accepts pickuplocationid as an alias so anything written against the earlier docs starts working instead of failing. Also gofmt on the two model files touched; booking.go was already failing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3598 lines
110 KiB
Go
3598 lines
110 KiB
Go
package controllers
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"doormile/config"
|
|
"doormile/constants"
|
|
"doormile/db"
|
|
"doormile/dto"
|
|
"doormile/internal/assignment"
|
|
"doormile/internal/notify"
|
|
"doormile/models"
|
|
"doormile/utils"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/redis/go-redis/v9"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// consoleTenantID returns the tenant a console login is restricted to, or 0
|
|
// for Doormile's own staff, who are unrestricted. Client logins carry their
|
|
// tenant in the JWT (see LoginAdmin); Doormile staff have DoormileAuth.Tenantid
|
|
// nil and so authenticate with 0.
|
|
func consoleTenantID(c *fiber.Ctx) int {
|
|
tenantID, ok := c.Locals("tenantid").(int)
|
|
if !ok {
|
|
return 0
|
|
}
|
|
return tenantID
|
|
}
|
|
|
|
// isDoormileConsoleStaff reports whether the caller sees every tenant's data.
|
|
func isDoormileConsoleStaff(c *fiber.Ctx) bool {
|
|
return consoleTenantID(c) == 0
|
|
}
|
|
|
|
// effectiveTenantID is the tenant a request should be read as: the caller's own
|
|
// for a client login, or whatever ?tenantid= asks for when Doormile staff want
|
|
// one client's slice of the network. Returns 0 for "no restriction", which only
|
|
// Doormile staff can reach.
|
|
//
|
|
// The second return is "allowed", not an error, and deliberately so: the
|
|
// utils.* response helpers all return c.JSON's nil, so a helper that signalled
|
|
// refusal by returning utils.Forbidden(...) would hand its caller a nil error.
|
|
// Every `if err != nil` guard built that way silently passes and the handler
|
|
// carries on to write real data into a response already stamped 403. Callers
|
|
// must write the refusal themselves.
|
|
func effectiveTenantID(c *fiber.Ctx) (tenantID int, allowed bool) {
|
|
own := consoleTenantID(c)
|
|
requested := c.QueryInt("tenantid", 0)
|
|
|
|
if own != 0 {
|
|
// A client asking for someone else's tenant is refused rather than
|
|
// silently given their own data back under the wrong label.
|
|
if requested != 0 && requested != own {
|
|
return 0, false
|
|
}
|
|
return own, true
|
|
}
|
|
return requested, true
|
|
}
|
|
|
|
// scopeToTenant restricts a query to one tenant by its own tenant column,
|
|
// no-oping when tenantID is 0.
|
|
func scopeToTenant(query *gorm.DB, column string, tenantID int) *gorm.DB {
|
|
if tenantID == 0 {
|
|
return query
|
|
}
|
|
return query.Where(column+" = ?", tenantID)
|
|
}
|
|
|
|
// requestedLocationID reads ?locationid= and proves the site belongs to the
|
|
// tenant the request is scoped to. Returns 0 for "all sites". The second value
|
|
// is "ok", not an error, for the reason on effectiveTenantID — utils.NotFound
|
|
// returns nil, so it cannot be used as a refusal signal.
|
|
//
|
|
// The ownership check matters because a location id is just an integer in a
|
|
// query string: without it a client could read another client's per-site
|
|
// numbers by guessing ids, which is the whole tenant boundary undone by one
|
|
// unvalidated param.
|
|
func requestedLocationID(c *fiber.Ctx, tenantID int) (locationID int, ok bool) {
|
|
locationID = c.QueryInt("locationid", 0)
|
|
if locationID == 0 {
|
|
return 0, true
|
|
}
|
|
|
|
var loc models.TenantLocation
|
|
if err := db.DB.Select("tenantlocationid", "tenantid").
|
|
Where("tenantlocationid = ?", locationID).First(&loc).Error; err != nil {
|
|
return 0, false
|
|
}
|
|
if tenantID != 0 && loc.Tenantid != tenantID {
|
|
return 0, false
|
|
}
|
|
return locationID, true
|
|
}
|
|
|
|
// scopeToLocation narrows a query to one client site, no-oping when locationID
|
|
// is 0.
|
|
func scopeToLocation(query *gorm.DB, column string, locationID int) *gorm.DB {
|
|
if locationID == 0 {
|
|
return query
|
|
}
|
|
return query.Where(column+" = ?", locationID)
|
|
}
|
|
|
|
// milerUserIDsForTenant lists the appusers.userid of a tenant's riders. Riders
|
|
// belong to a client through their appusers row, not the miler profile.
|
|
func milerUserIDsForTenant(tenantID int) []int {
|
|
var ids []int
|
|
db.DB.Model(&models.AppUser{}).
|
|
Where("tenantid = ? AND roleid = ?", tenantID, 5).Pluck("userid", &ids)
|
|
return ids
|
|
}
|
|
|
|
// scopeToOwnTenant restricts a query on a tenant-owned table to the requesting
|
|
// console user's own tenant. Doormile staff are unrestricted. This is the
|
|
// admin-console counterpart of scopeBookingsToOwnTenant in hubController.go —
|
|
// without it, any client given an express-console login reads every other
|
|
// client's data.
|
|
//
|
|
// The column name is taken as a parameter because the tenant key is not always
|
|
// literally "tenantid": on the tenants table itself it is the primary key.
|
|
func scopeToOwnTenant(c *fiber.Ctx, query *gorm.DB, column string) *gorm.DB {
|
|
tenantID := consoleTenantID(c)
|
|
if tenantID == 0 {
|
|
return query
|
|
}
|
|
return query.Where(column+" = ?", tenantID)
|
|
}
|
|
|
|
// scopeViaBookings restricts a query to rows whose foreign key appears on one of
|
|
// the tenant's bookings. Customers carry no tenant of their own — the same
|
|
// person can order from two different clients — so the relationship only exists
|
|
// through the bookings placed for them.
|
|
func scopeViaBookings(c *fiber.Ctx, query *gorm.DB, column string) *gorm.DB {
|
|
tenantID := consoleTenantID(c)
|
|
if tenantID == 0 {
|
|
return query
|
|
}
|
|
return query.Where(column+" IN (?)",
|
|
db.DB.Model(&models.PickupBooking{}).Select("appcustomerid").Where("tenantid = ?", tenantID))
|
|
}
|
|
|
|
// scopeViaConsignments restricts a query to rows attached to one of the tenant's
|
|
// consignments — used for exceptions, which inherit their owner from the parcel
|
|
// they were raised against.
|
|
func scopeViaConsignments(c *fiber.Ctx, query *gorm.DB, column string) *gorm.DB {
|
|
tenantID := consoleTenantID(c)
|
|
if tenantID == 0 {
|
|
return query
|
|
}
|
|
return query.Where(column+" IN (?)",
|
|
db.DB.Model(&models.Consignment{}).Select("consignmentid").Where("tenantid = ?", tenantID))
|
|
}
|
|
|
|
// canAccessTenant reports whether the caller may act on the given tenant.
|
|
// Used where the tenant is addressed by a path parameter or request body rather
|
|
// than filtered in a query — scoping a WHERE clause does nothing when the
|
|
// caller names the tenant directly.
|
|
func canAccessTenant(c *fiber.Ctx, tenantID int) bool {
|
|
own := consoleTenantID(c)
|
|
return own == 0 || own == tenantID
|
|
}
|
|
|
|
// canAccessBooking reports whether the caller may act on a booking addressed by
|
|
// id. Always true for Doormile staff. Mutating handlers take the booking id
|
|
// straight from the path, so a scoped SELECT elsewhere in the handler does not
|
|
// protect them — this has to run before the write.
|
|
//
|
|
// Returns a bool rather than an error for the reason spelled out on
|
|
// effectiveTenantID: utils.NotFound returns nil, so the previous
|
|
// error-returning version never actually blocked anything.
|
|
func canAccessBooking(c *fiber.Ctx, bookingID int) bool {
|
|
own := consoleTenantID(c)
|
|
if own == 0 {
|
|
return true
|
|
}
|
|
var booking models.PickupBooking
|
|
if err := db.DB.Select("bookingid", "tenantid").First(&booking, bookingID).Error; err != nil {
|
|
return false
|
|
}
|
|
// A booking with no tenant predates tenant attribution and can't be proven
|
|
// to belong to this client, so it stays invisible to them.
|
|
return booking.Tenantid != nil && *booking.Tenantid == own
|
|
}
|
|
|
|
// Helper to generate tripsheet number
|
|
func generateTripsheetNo() string {
|
|
b := make([]byte, 4)
|
|
rand.Read(b)
|
|
return fmt.Sprintf("DM-TS-%X-%d", b, time.Now().Unix()%100000)
|
|
}
|
|
|
|
func LoginAdmin(cfg *config.Config) fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
req := new(dto.AdminLoginRequest)
|
|
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 auth models.DoormileAuth
|
|
if err := db.DB.Where("email = ?", req.Email).First(&auth).Error; err != nil {
|
|
return utils.Unauthorized(c, "incorrect email or password")
|
|
}
|
|
|
|
if auth.Role != "admin" && auth.Role != "manager" && auth.Role != "executive" {
|
|
return utils.Forbidden(c, "access restricted to admin console users")
|
|
}
|
|
|
|
if !utils.CheckPasswordHash(req.Password, auth.PasswordHash) {
|
|
return utils.Unauthorized(c, "incorrect email or password")
|
|
}
|
|
|
|
roleId := 3
|
|
if auth.Role == "admin" {
|
|
roleId = 1
|
|
} else if auth.Role == "executive" {
|
|
roleId = 4
|
|
}
|
|
|
|
var appUser models.AppUser
|
|
db.DB.Where("email = ?", req.Email).First(&appUser)
|
|
userName := "Admin"
|
|
if appUser.Authname != "" {
|
|
userName = appUser.Authname
|
|
}
|
|
|
|
// A client's console login carries their tenant so handlers can scope to
|
|
// it; Doormile's own staff have Tenantid nil and keep tenantID 0, which
|
|
// scopeToOwnTenant reads as "unrestricted". Emitting 0 unconditionally
|
|
// (as this did) is what left every console login able to read every
|
|
// tenant's data.
|
|
tenantID := 0
|
|
if auth.Tenantid != nil {
|
|
tenantID = *auth.Tenantid
|
|
}
|
|
|
|
// Important: use appUser.Userid instead of auth.ID to ensure consistent IDs across the system
|
|
token, err := utils.GenerateToken(int(appUser.Userid), auth.Email, roleId, tenantID, 1, cfg.JWTSecret)
|
|
if err != nil {
|
|
return utils.Internal(c, "failed to generate token")
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"success": true,
|
|
"token": token,
|
|
"user": fiber.Map{
|
|
"id": appUser.Userid,
|
|
"name": userName,
|
|
"email": auth.Email,
|
|
"role": auth.Role,
|
|
"tenantid": auth.Tenantid,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
func GetAdminDashboard(c *fiber.Ctx) error {
|
|
// Pinned to the caller's own tenant for a client login; Doormile staff pass
|
|
// ?tenantid= to see one client's numbers, or omit it for the whole network.
|
|
tenantID, allowed := effectiveTenantID(c)
|
|
if !allowed {
|
|
return utils.Forbidden(c, "you can only view your own tenant")
|
|
}
|
|
|
|
var totalTenants int64
|
|
var totalCustomers int64
|
|
var totalMilers int64
|
|
var totalBookings int64
|
|
var totalConsignments int64
|
|
var openExceptions int64
|
|
|
|
scopeToTenant(db.DB.Model(&models.Tenant{}), "tenantid", tenantID).Count(&totalTenants)
|
|
scopeToTenant(db.DB.Model(&models.PickupBooking{}), "tenantid", tenantID).Count(&totalBookings)
|
|
scopeToTenant(db.DB.Model(&models.Consignment{}), "tenantid", tenantID).Count(&totalConsignments)
|
|
|
|
// Customers and exceptions carry no tenant column of their own, so they are
|
|
// counted through the bookings and consignments that do. Riders link to a
|
|
// client through appusers.tenantid. Reporting these as zero (which this did
|
|
// for every client login) left a client's dashboard looking like an empty
|
|
// account on the day they first signed in.
|
|
if tenantID == 0 {
|
|
db.DB.Model(&models.AppCustomer{}).Count(&totalCustomers)
|
|
db.DB.Model(&models.AppUser{}).Where("roleid = 5").Count(&totalMilers)
|
|
db.DB.Model(&models.ConsignmentException{}).Where("status = ?", "Open").Count(&openExceptions)
|
|
} else {
|
|
db.DB.Model(&models.AppCustomer{}).
|
|
Where("appcustomerid IN (?)", db.DB.Model(&models.PickupBooking{}).
|
|
Select("appcustomerid").Where("tenantid = ?", tenantID)).
|
|
Count(&totalCustomers)
|
|
db.DB.Model(&models.AppUser{}).
|
|
Where("roleid = 5 AND tenantid = ?", tenantID).Count(&totalMilers)
|
|
db.DB.Model(&models.ConsignmentException{}).
|
|
Where("status = ? AND consignmentid IN (?)", "Open", db.DB.Model(&models.Consignment{}).
|
|
Select("consignmentid").Where("tenantid = ?", tenantID)).
|
|
Count(&openExceptions)
|
|
}
|
|
|
|
return utils.OK(c, fiber.Map{
|
|
"tenantid": tenantID,
|
|
"tenants": totalTenants,
|
|
"customers": totalCustomers,
|
|
"milers": totalMilers,
|
|
"bookings": totalBookings,
|
|
"consignments": totalConsignments,
|
|
"exceptions": openExceptions,
|
|
})
|
|
}
|
|
|
|
// --------------------
|
|
// REPORTS
|
|
// --------------------
|
|
|
|
// GetAdminReports gives operations dashboard-style aggregates over a date
|
|
// range (defaults to today via parseHubDateRange, same helper GetHubReport
|
|
// uses), optionally scoped to one tenant/hub, broken down by hub, tenant, and
|
|
// rider. Replaces the old system's separate getreportsummary /
|
|
// getriderlocationsummary / getridersummary endpoints with one
|
|
// parameterized view instead of three fixed ones.
|
|
func GetAdminReports(c *fiber.Ctx) error {
|
|
from, to, err := parseHubDateRange(c)
|
|
if err != nil {
|
|
return utils.BadRequest(c, err.Error())
|
|
}
|
|
|
|
hubID := c.Query("hubid")
|
|
|
|
// ownTenant is 0 for a Doormile-staff whole-network view, the client's own
|
|
// tenant for a client login, or the tenant Doormile staff asked for with
|
|
// ?tenantid=. Every figure below is restricted to it.
|
|
ownTenant, allowed := effectiveTenantID(c)
|
|
if !allowed {
|
|
return utils.Forbidden(c, "you can only view your own tenant")
|
|
}
|
|
|
|
// ?locationid= narrows every figure to one of the client's sites — a single
|
|
// kitchen, branch or depot. This was jupiter's getreportsummary locationid
|
|
// param; without it a food client with 23 kitchens can only see one number
|
|
// for all of them.
|
|
locationID, locOK := requestedLocationID(c, ownTenant)
|
|
if !locOK {
|
|
return utils.NotFound(c, "location not found")
|
|
}
|
|
|
|
bookingScope := func() *gorm.DB {
|
|
q := scopeToTenant(db.DB.Model(&models.PickupBooking{}), "tenantid", ownTenant)
|
|
return scopeToLocation(q, "tenantlocationid", locationID)
|
|
}
|
|
|
|
var totalBookings int64
|
|
bookingScope().Where("createdat BETWEEN ? AND ?", from, to).Count(&totalBookings)
|
|
|
|
var delivered int64
|
|
bookingScope().
|
|
Where("status = ? AND updatedat BETWEEN ? AND ?", constants.BookingConvertedConsignment, from, to).
|
|
Count(&delivered)
|
|
|
|
var cancelled int64
|
|
bookingScope().
|
|
Where("status = ? AND updatedat BETWEEN ? AND ?", constants.BookingCancelled, from, to).
|
|
Count(&cancelled)
|
|
|
|
consignmentQuery := scopeToLocation(
|
|
scopeToTenant(db.DB.Model(&models.Consignment{}), "tenantid", ownTenant),
|
|
"tenantlocationid", locationID).
|
|
Where("createdat BETWEEN ? AND ?", from, to)
|
|
if hubID != "" {
|
|
consignmentQuery = consignmentQuery.Where("currenthubid = ?", hubID)
|
|
}
|
|
var totalConsignments int64
|
|
consignmentQuery.Count(&totalConsignments)
|
|
|
|
// Payments and exceptions carry no tenant column, so they're restricted
|
|
// through the bookings/consignments they belong to. COD in particular is a
|
|
// figure a client genuinely needs, so it's joined rather than suppressed.
|
|
var codCollected float64
|
|
codQuery := db.DB.Model(&models.BookingPayment{}).
|
|
Where("paymentstatus = ? AND createdat BETWEEN ? AND ?", constants.PaymentStatusPaid, from, to)
|
|
if ownTenant != 0 {
|
|
codQuery = codQuery.Where("bookingid IN (?)",
|
|
db.DB.Model(&models.PickupBooking{}).Select("bookingid").Where("tenantid = ?", ownTenant))
|
|
}
|
|
codQuery.Select("COALESCE(SUM(amount), 0)").Scan(&codCollected)
|
|
|
|
var openExceptions int64
|
|
excQuery := db.DB.Model(&models.ConsignmentException{}).
|
|
Where("status != ? AND createdat BETWEEN ? AND ?", constants.ExceptionClosed, from, to)
|
|
if ownTenant != 0 {
|
|
excQuery = excQuery.Where("consignmentid IN (?)",
|
|
db.DB.Model(&models.Consignment{}).Select("consignmentid").Where("tenantid = ?", ownTenant))
|
|
}
|
|
excQuery.Count(&openExceptions)
|
|
|
|
completionRate := 0.0
|
|
if totalBookings > 0 {
|
|
completionRate = float64(delivered) / float64(totalBookings) * 100
|
|
}
|
|
|
|
// ---- by hub: parcels delivered through each hub in range ----
|
|
type hubRow struct {
|
|
Hubid int `gorm:"column:hubid"`
|
|
Hubname string `gorm:"column:hubname"`
|
|
Delivered int64 `gorm:"column:delivered"`
|
|
}
|
|
var hubRows []hubRow
|
|
// The tenant filter belongs in the JOIN condition, not a WHERE — in a WHERE
|
|
// it would drop hubs with no matching parcels instead of showing them as
|
|
// zero.
|
|
hubSQL := `
|
|
SELECT h.hubid AS hubid, h.hubname AS hubname, COUNT(c.consignmentid) AS delivered
|
|
FROM hubs h
|
|
LEFT JOIN consignments c ON c.currenthubid = h.hubid AND c.status = ? AND c.updatedat BETWEEN ? AND ?`
|
|
hubArgs := []interface{}{constants.ConsignmentDelivered, from, to}
|
|
if ownTenant != 0 {
|
|
hubSQL += ` AND c.tenantid = ?`
|
|
hubArgs = append(hubArgs, ownTenant)
|
|
}
|
|
hubSQL += `
|
|
WHERE h.deletedat IS NULL
|
|
GROUP BY h.hubid, h.hubname
|
|
ORDER BY delivered DESC`
|
|
db.DB.Raw(hubSQL, hubArgs...).Scan(&hubRows)
|
|
|
|
byHub := make([]fiber.Map, 0, len(hubRows))
|
|
for _, r := range hubRows {
|
|
byHub = append(byHub, fiber.Map{"hubid": r.Hubid, "hubname": r.Hubname, "delivered": r.Delivered})
|
|
}
|
|
|
|
// ---- by tenant: consignments shipped for each tenant in range ----
|
|
type tenantRow struct {
|
|
Tenantid int `gorm:"column:tenantid"`
|
|
Tenantname string `gorm:"column:tenantname"`
|
|
Bookings int64 `gorm:"column:bookings"`
|
|
}
|
|
var tenantRows []tenantRow
|
|
tenantSQL := `
|
|
SELECT t.tenantid AS tenantid, t.tenantname AS tenantname, COUNT(c.consignmentid) AS bookings
|
|
FROM tenants t
|
|
LEFT JOIN consignments c ON c.tenantid = t.tenantid AND c.createdat BETWEEN ? AND ?`
|
|
tenantArgs := []interface{}{from, to}
|
|
if ownTenant != 0 {
|
|
tenantSQL += ` WHERE t.tenantid = ?`
|
|
tenantArgs = append(tenantArgs, ownTenant)
|
|
}
|
|
tenantSQL += `
|
|
GROUP BY t.tenantid, t.tenantname
|
|
ORDER BY bookings DESC`
|
|
db.DB.Raw(tenantSQL, tenantArgs...).Scan(&tenantRows)
|
|
|
|
byTenant := make([]fiber.Map, 0, len(tenantRows))
|
|
for _, r := range tenantRows {
|
|
byTenant = append(byTenant, fiber.Map{"tenantid": r.Tenantid, "tenantname": r.Tenantname, "bookings": r.Bookings})
|
|
}
|
|
|
|
// ---- by location: what went out of each of the client's own sites ----
|
|
// For a food client this is "how many orders left which kitchen", which is
|
|
// the question a single network-wide total cannot answer.
|
|
byLocation := locationBreakdown(ownTenant, locationID, from, to)
|
|
|
|
// ---- by rider: completed stops/kms/earnings per rider in range,
|
|
// optionally scoped to one hub ----
|
|
type riderRow struct {
|
|
Userid int `gorm:"column:userid"`
|
|
Displayname string `gorm:"column:displayname"`
|
|
CompletedStops int64 `gorm:"column:completed_stops"`
|
|
TotalKms float64 `gorm:"column:total_kms"`
|
|
TotalEarnings float64 `gorm:"column:total_earnings"`
|
|
}
|
|
// Rider earnings, kms and completed stops are Doormile's own workforce data
|
|
// — a client has no business reading them, and there's no per-client view
|
|
// of a rider who works across tenants. Clients get an empty list.
|
|
var riderRows []riderRow
|
|
riderQuery := `
|
|
SELECT mp.userid AS userid, mp.displayname AS displayname,
|
|
COUNT(ba.bookingassignmentid) AS completed_stops,
|
|
COALESCE(SUM(ba.riderkms),0) AS total_kms,
|
|
COALESCE(SUM(ba.ridercharges),0) AS total_earnings
|
|
FROM milerprofiles mp
|
|
JOIN bookingassignments ba ON ba.mileruserid = mp.userid
|
|
AND ba.assignmentstatus = ? AND ba.completedat BETWEEN ? AND ?
|
|
`
|
|
args := []interface{}{constants.AssignmentCompleted, from, to}
|
|
where := []string{}
|
|
if hubID != "" {
|
|
where = append(where, "mp.hubid = ?")
|
|
args = append(args, hubID)
|
|
}
|
|
// A client sees its own riders' numbers, not the network's. Previously this
|
|
// list was simply empty for every client login, which reads as "your riders
|
|
// did nothing" rather than "this view isn't for you".
|
|
if ownTenant != 0 {
|
|
where = append(where, "mp.userid IN (SELECT userid FROM appusers WHERE tenantid = ? AND roleid = 5)")
|
|
args = append(args, ownTenant)
|
|
}
|
|
if len(where) > 0 {
|
|
riderQuery += " WHERE " + strings.Join(where, " AND ")
|
|
}
|
|
riderQuery += " GROUP BY mp.userid, mp.displayname ORDER BY completed_stops DESC LIMIT 50"
|
|
db.DB.Raw(riderQuery, args...).Scan(&riderRows)
|
|
|
|
byRider := make([]fiber.Map, 0, len(riderRows))
|
|
for _, r := range riderRows {
|
|
byRider = append(byRider, fiber.Map{
|
|
"userid": r.Userid, "displayname": r.Displayname,
|
|
"completed_stops": r.CompletedStops, "total_kms": r.TotalKms, "total_earnings": r.TotalEarnings,
|
|
})
|
|
}
|
|
|
|
return utils.OK(c, fiber.Map{
|
|
"from": from.Format("2006-01-02"),
|
|
"to": to.Format("2006-01-02"),
|
|
"tenantid": ownTenant,
|
|
"locationid": locationID,
|
|
"summary": fiber.Map{
|
|
"total_bookings": totalBookings,
|
|
"delivered": delivered,
|
|
"cancelled": cancelled,
|
|
"total_consignments": totalConsignments,
|
|
"cod_collected": codCollected,
|
|
"open_exceptions": openExceptions,
|
|
"completion_rate": completionRate,
|
|
},
|
|
"by_hub": byHub,
|
|
"by_tenant": byTenant,
|
|
"by_location": byLocation,
|
|
"by_rider": byRider,
|
|
})
|
|
}
|
|
|
|
// matchTenantLocation resolves which of a client's sites a pickup came from,
|
|
// for callers that send an address instead of a location id. Returns nil when
|
|
// nothing matches confidently — a wrong attribution is worse than none, since
|
|
// it silently moves orders between kitchens on the report.
|
|
//
|
|
// Coordinates first: they are unambiguous where an address string is not
|
|
// ("Vidhya kitchen, Ritham Tours & Travels, Peelamedu" versus the same site
|
|
// stored as "Ritham Tours and Travels, Peelamedu"). Address matching is only a
|
|
// fallback for bookings that arrive without coordinates.
|
|
func matchTenantLocation(tenantID int, address string, lat, lon float64) *int {
|
|
var locations []models.TenantLocation
|
|
if err := db.DB.Where("tenantid = ?", tenantID).Find(&locations).Error; err != nil || len(locations) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// 150m: tight enough that two kitchens on the same street stay distinct,
|
|
// loose enough to absorb the drift between a stored pin and the one the
|
|
// console sends.
|
|
const matchRadiusKM = 0.15
|
|
|
|
if lat != 0 || lon != 0 {
|
|
best := -1
|
|
bestDist := matchRadiusKM
|
|
for i, loc := range locations {
|
|
if loc.Latitude == 0 && loc.Longitude == 0 {
|
|
continue
|
|
}
|
|
if d := haversineKM(lat, lon, loc.Latitude, loc.Longitude); d < bestDist {
|
|
best, bestDist = i, d
|
|
}
|
|
}
|
|
if best >= 0 {
|
|
id := locations[best].Tenantlocationid
|
|
return &id
|
|
}
|
|
}
|
|
|
|
if address == "" {
|
|
return nil
|
|
}
|
|
needle := strings.ToLower(strings.TrimSpace(address))
|
|
for _, loc := range locations {
|
|
stored := strings.ToLower(strings.TrimSpace(loc.Address))
|
|
if stored == "" {
|
|
continue
|
|
}
|
|
// Exact either way round, so "Vidhya kitchen, <address>" still resolves
|
|
// when the stored row holds just the address. Substring matching is
|
|
// deliberately not loosened past this — a short stored address would
|
|
// otherwise swallow unrelated pickups.
|
|
if stored == needle || strings.Contains(needle, stored) {
|
|
id := loc.Tenantlocationid
|
|
return &id
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetLocationSummary is the standalone per-site table — jupiter's
|
|
// getlocationsummary. Same rows as the report's by_location block, without
|
|
// pulling the whole report, so a "Kitchens" screen can load on its own.
|
|
//
|
|
// GET /admin/locations/summary?tenantid=&locationid=&from=&to=
|
|
func GetLocationSummary(c *fiber.Ctx) error {
|
|
from, to, err := parseHubDateRange(c)
|
|
if err != nil {
|
|
return utils.BadRequest(c, err.Error())
|
|
}
|
|
|
|
tenantID, allowed := effectiveTenantID(c)
|
|
if !allowed {
|
|
return utils.Forbidden(c, "you can only view your own tenant")
|
|
}
|
|
// Doormile staff must name a client: per-site rows across every tenant at
|
|
// once are a mix of unrelated sites, not a report.
|
|
if tenantID == 0 {
|
|
return utils.BadRequest(c, "tenantid is required — per-site figures are reported within one client")
|
|
}
|
|
|
|
locationID, locOK := requestedLocationID(c, tenantID)
|
|
if !locOK {
|
|
return utils.NotFound(c, "location not found")
|
|
}
|
|
|
|
rows := locationBreakdown(tenantID, locationID, from, to)
|
|
return c.JSON(fiber.Map{
|
|
"success": true,
|
|
"data": rows,
|
|
"total": len(rows),
|
|
"tenantid": tenantID,
|
|
"locationid": locationID,
|
|
"from": from.Format("2006-01-02"),
|
|
"to": to.Format("2006-01-02"),
|
|
})
|
|
}
|
|
|
|
// locationBreakdown returns per-site totals for a tenant: how many bookings
|
|
// each of the client's own locations raised in the range, how many reached a
|
|
// consignment, and what COD came back.
|
|
//
|
|
// Bookings with no tenantlocationid are reported as a single "Unattributed"
|
|
// row rather than dropped, so the per-site figures still add up to the summary
|
|
// total. That row is not noise — it is every booking created without naming
|
|
// its site, and it should shrink to zero as the console starts sending
|
|
// tenantlocationid.
|
|
func locationBreakdown(tenantID, locationID int, from, to time.Time) []fiber.Map {
|
|
// Only meaningful within one client. Across the whole network the rows
|
|
// would be a mix of every tenant's sites, which is a screen nobody asked
|
|
// for; Doormile staff pass ?tenantid= to get this.
|
|
if tenantID == 0 {
|
|
return []fiber.Map{}
|
|
}
|
|
|
|
type locRow struct {
|
|
Tenantlocationid int `gorm:"column:tenantlocationid"`
|
|
Locationname string `gorm:"column:locationname"`
|
|
Address string `gorm:"column:address"`
|
|
Pincode string `gorm:"column:pincode"`
|
|
Bookings int64 `gorm:"column:bookings"`
|
|
Delivered int64 `gorm:"column:delivered"`
|
|
Cancelled int64 `gorm:"column:cancelled"`
|
|
CodCollected float64 `gorm:"column:cod_collected"`
|
|
}
|
|
|
|
// The date range sits in the JOIN, not a WHERE, so a site with no orders in
|
|
// the window still appears with zeros instead of vanishing from the report.
|
|
//
|
|
// Payments are pre-aggregated per booking before joining. Joining
|
|
// bookingpayments directly would fan out — a booking with two payment rows
|
|
// would be counted as two bookings.
|
|
sql := `
|
|
SELECT tl.tenantlocationid AS tenantlocationid,
|
|
tl.locationname AS locationname,
|
|
tl.address AS address,
|
|
tl.pincode AS pincode,
|
|
COUNT(b.bookingid) AS bookings,
|
|
COUNT(b.bookingid) FILTER (WHERE b.status = ?) AS delivered,
|
|
COUNT(b.bookingid) FILTER (WHERE b.status = ?) AS cancelled,
|
|
COALESCE(SUM(p.paid), 0) AS cod_collected
|
|
FROM tenantlocations tl
|
|
LEFT JOIN pickupbookings b
|
|
ON b.tenantlocationid = tl.tenantlocationid
|
|
AND b.createdat BETWEEN ? AND ?
|
|
LEFT JOIN (
|
|
SELECT bookingid, SUM(amount) AS paid
|
|
FROM bookingpayments
|
|
WHERE paymentstatus = ?
|
|
GROUP BY bookingid
|
|
) p ON p.bookingid = b.bookingid
|
|
WHERE tl.tenantid = ?`
|
|
args := []interface{}{
|
|
constants.BookingConvertedConsignment, constants.BookingCancelled,
|
|
from, to, constants.PaymentStatusPaid, tenantID,
|
|
}
|
|
if locationID != 0 {
|
|
sql += ` AND tl.tenantlocationid = ?`
|
|
args = append(args, locationID)
|
|
}
|
|
sql += `
|
|
GROUP BY tl.tenantlocationid, tl.locationname, tl.address, tl.pincode
|
|
ORDER BY bookings DESC, tl.locationname`
|
|
|
|
var rows []locRow
|
|
db.DB.Raw(sql, args...).Scan(&rows)
|
|
|
|
out := make([]fiber.Map, 0, len(rows)+1)
|
|
for _, r := range rows {
|
|
out = append(out, fiber.Map{
|
|
"tenantlocationid": r.Tenantlocationid,
|
|
"locationname": r.Locationname,
|
|
"address": r.Address,
|
|
"pincode": r.Pincode,
|
|
"bookings": r.Bookings,
|
|
"delivered": r.Delivered,
|
|
"cancelled": r.Cancelled,
|
|
"cod_collected": r.CodCollected,
|
|
})
|
|
}
|
|
|
|
// Anything the client raised without naming a site. Suppressed when the
|
|
// caller asked for one specific location.
|
|
if locationID == 0 {
|
|
var unattributed int64
|
|
db.DB.Model(&models.PickupBooking{}).
|
|
Where("tenantid = ? AND tenantlocationid IS NULL AND createdat BETWEEN ? AND ?",
|
|
tenantID, from, to).Count(&unattributed)
|
|
if unattributed > 0 {
|
|
out = append(out, fiber.Map{
|
|
"tenantlocationid": nil,
|
|
"locationname": "Unattributed",
|
|
"address": "",
|
|
"pincode": "",
|
|
"bookings": unattributed,
|
|
"delivered": 0,
|
|
"cancelled": 0,
|
|
"cod_collected": 0,
|
|
})
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// --------------------
|
|
// APP USERS MANAGEMENT
|
|
// --------------------
|
|
|
|
func GetAppUsers(c *fiber.Ctx) error {
|
|
page := utils.ParsePage(c)
|
|
|
|
var total int64
|
|
if err := scopeToOwnTenant(c, db.DB.Model(&models.AppUser{}), "tenantid").
|
|
Where("roleid != ?", 5).Count(&total).Error; err != nil {
|
|
return utils.Internal(c, "failed to count users")
|
|
}
|
|
|
|
var users []models.AppUser
|
|
// Exclude Milers (Roleid = 5) from the CRM user list. Scoped as well, so a
|
|
// client sees only their own people, not Doormile's staff directory.
|
|
if err := page.Apply(scopeToOwnTenant(c, db.DB, "tenantid").Where("roleid != ?", 5)).
|
|
Find(&users).Error; err != nil {
|
|
return utils.Internal(c, "failed to fetch users")
|
|
}
|
|
|
|
response := make([]fiber.Map, 0, len(users))
|
|
for _, u := range users {
|
|
roleName := "unknown"
|
|
if u.Roleid == 1 {
|
|
roleName = "admin"
|
|
} else if u.Roleid == 3 {
|
|
roleName = "manager"
|
|
} else if u.Roleid == 4 {
|
|
roleName = "rep"
|
|
} else if u.Roleid == 5 {
|
|
roleName = "miler"
|
|
}
|
|
|
|
response = append(response, fiber.Map{
|
|
"id": u.Userid,
|
|
"first_name": u.Authname,
|
|
"email": u.Email,
|
|
"phone": u.Contactno,
|
|
"role": roleName,
|
|
"status": u.Status,
|
|
})
|
|
}
|
|
return utils.Paginated(c, response, total, page)
|
|
}
|
|
|
|
func CreateAppUser(c *fiber.Ctx) error {
|
|
type UserRequest struct {
|
|
FirstName string `json:"first_name"`
|
|
Email string `json:"email"`
|
|
Phone string `json:"phone"`
|
|
Role string `json:"role"`
|
|
Password string `json:"password"`
|
|
}
|
|
req := new(UserRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
roleId := 4
|
|
if req.Role == "admin" {
|
|
roleId = 1
|
|
} else if req.Role == "manager" {
|
|
roleId = 3
|
|
} else if req.Role == "rep" {
|
|
roleId = 4
|
|
}
|
|
|
|
passHash, _ := utils.HashPassword(req.Password)
|
|
if passHash == "" {
|
|
passHash, _ = utils.HashPassword("defaultPassword123")
|
|
}
|
|
|
|
user := models.AppUser{
|
|
Authname: req.FirstName,
|
|
Email: req.Email,
|
|
Contactno: req.Phone,
|
|
Password: passHash,
|
|
Roleid: roleId,
|
|
Status: "Active",
|
|
Applocationid: 1,
|
|
}
|
|
|
|
if err := db.DB.Create(&user).Error; err != nil {
|
|
return utils.Internal(c, "failed to create user")
|
|
}
|
|
|
|
return utils.Created(c, fiber.Map{
|
|
"id": user.Userid,
|
|
"first_name": user.Authname,
|
|
"email": user.Email,
|
|
"phone": user.Contactno,
|
|
"role": req.Role,
|
|
})
|
|
}
|
|
|
|
func UpdateAppUser(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var user models.AppUser
|
|
if err := db.DB.First(&user, id).Error; err != nil {
|
|
return utils.NotFound(c, "user not found")
|
|
}
|
|
|
|
type UserRequest struct {
|
|
FirstName string `json:"first_name"`
|
|
Email string `json:"email"`
|
|
Phone string `json:"phone"`
|
|
Role string `json:"role"`
|
|
}
|
|
req := new(UserRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if req.FirstName != "" {
|
|
user.Authname = req.FirstName
|
|
}
|
|
if req.Email != "" {
|
|
user.Email = req.Email
|
|
}
|
|
if req.Phone != "" {
|
|
user.Contactno = req.Phone
|
|
}
|
|
if req.Role != "" {
|
|
if req.Role == "admin" {
|
|
user.Roleid = 1
|
|
} else if req.Role == "manager" {
|
|
user.Roleid = 3
|
|
} else if req.Role == "rep" {
|
|
user.Roleid = 4
|
|
}
|
|
}
|
|
user.Updatedat = time.Now()
|
|
|
|
if err := db.DB.Save(&user).Error; err != nil {
|
|
return utils.Internal(c, "failed to update user")
|
|
}
|
|
return utils.OK(c, fiber.Map{
|
|
"id": user.Userid,
|
|
"first_name": user.Authname,
|
|
"email": user.Email,
|
|
"phone": user.Contactno,
|
|
"role": req.Role,
|
|
})
|
|
}
|
|
|
|
func DeleteAppUser(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
if err := db.DB.Delete(&models.AppUser{}, id).Error; err != nil {
|
|
return utils.Internal(c, "failed to delete user")
|
|
}
|
|
return utils.Message(c, "user deleted successfully")
|
|
}
|
|
|
|
// --------------------
|
|
// TENANT MANAGEMENT
|
|
// --------------------
|
|
|
|
func GetTenants(c *fiber.Ctx) error {
|
|
var tenants []models.Tenant
|
|
// A client login sees only its own tenant row; the tenant key here is the
|
|
// primary key, not a "tenantid" foreign column.
|
|
if err := scopeToOwnTenant(c, db.DB, "tenantid").Find(&tenants).Error; err != nil {
|
|
return utils.Internal(c, "failed to fetch tenants")
|
|
}
|
|
return utils.List(c, tenants, int64(len(tenants)))
|
|
}
|
|
|
|
func CreateTenant(c *fiber.Ctx) error {
|
|
req := new(dto.TenantCreateRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
tenant := models.Tenant{
|
|
Tenantname: req.Tenantname,
|
|
Primaryemail: req.Primaryemail,
|
|
Primarycontact: req.Primarycontact,
|
|
Status: req.Status,
|
|
}
|
|
if tenant.Status == "" {
|
|
tenant.Status = "Active"
|
|
}
|
|
if req.Requiredeliveryotp != nil {
|
|
tenant.Requiredeliveryotp = *req.Requiredeliveryotp
|
|
}
|
|
|
|
if err := db.DB.Create(&tenant).Error; err != nil {
|
|
return utils.Internal(c, "failed to create tenant")
|
|
}
|
|
return utils.Created(c, tenant)
|
|
}
|
|
|
|
func GetTenantDetails(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var tenant models.Tenant
|
|
if err := scopeToOwnTenant(c, db.DB, "tenantid").First(&tenant, id).Error; err != nil {
|
|
return utils.NotFound(c, "tenant not found")
|
|
}
|
|
return utils.OK(c, tenant)
|
|
}
|
|
|
|
func UpdateTenant(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
if !canAccessTenant(c, id) {
|
|
return utils.Forbidden(c, "not permitted for this tenant")
|
|
}
|
|
var tenant models.Tenant
|
|
if err := db.DB.First(&tenant, id).Error; err != nil {
|
|
return utils.NotFound(c, "tenant not found")
|
|
}
|
|
|
|
req := new(dto.TenantCreateRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if req.Tenantname != "" {
|
|
tenant.Tenantname = req.Tenantname
|
|
}
|
|
if req.Primaryemail != "" {
|
|
tenant.Primaryemail = req.Primaryemail
|
|
}
|
|
if req.Primarycontact != "" {
|
|
tenant.Primarycontact = req.Primarycontact
|
|
}
|
|
if req.Status != "" {
|
|
tenant.Status = req.Status
|
|
}
|
|
if req.Requiredeliveryotp != nil {
|
|
tenant.Requiredeliveryotp = *req.Requiredeliveryotp
|
|
}
|
|
tenant.Updatedat = time.Now()
|
|
|
|
if err := db.DB.Save(&tenant).Error; err != nil {
|
|
return utils.Internal(c, "failed to update tenant")
|
|
}
|
|
return utils.OK(c, tenant)
|
|
}
|
|
|
|
func DeleteTenant(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
// Deleting your own tenant is not a client operation either — this is
|
|
// Doormile-staff only.
|
|
if !isDoormileConsoleStaff(c) {
|
|
return utils.Forbidden(c, "not permitted for this tenant")
|
|
}
|
|
var tenant models.Tenant
|
|
if err := db.DB.First(&tenant, id).Error; err != nil {
|
|
return utils.NotFound(c, "tenant not found")
|
|
}
|
|
|
|
if err := db.DB.Delete(&tenant).Error; err != nil {
|
|
return utils.Internal(c, "failed to delete tenant")
|
|
}
|
|
return utils.Message(c, "tenant deleted successfully")
|
|
}
|
|
|
|
func GetTenantLocations(c *fiber.Ctx) error {
|
|
tenantID, _ := strconv.Atoi(c.Params("id"))
|
|
if !canAccessTenant(c, tenantID) {
|
|
return utils.Forbidden(c, "not permitted for this tenant")
|
|
}
|
|
var locations []models.TenantLocation
|
|
if err := db.DB.Where("tenantid = ?", tenantID).Find(&locations).Error; err != nil {
|
|
return utils.Internal(c, "failed to fetch locations")
|
|
}
|
|
return utils.List(c, locations, int64(len(locations)))
|
|
}
|
|
|
|
func CreateTenantLocation(c *fiber.Ctx) error {
|
|
tenantID, _ := strconv.Atoi(c.Params("id"))
|
|
if !canAccessTenant(c, tenantID) {
|
|
return utils.Forbidden(c, "not permitted for this tenant")
|
|
}
|
|
req := new(dto.TenantLocationCreateRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
location := models.TenantLocation{
|
|
Tenantid: tenantID,
|
|
Locationname: req.Locationname,
|
|
Address: req.Address,
|
|
City: req.City,
|
|
State: req.State,
|
|
Pincode: req.Pincode,
|
|
Latitude: req.Latitude,
|
|
Longitude: req.Longitude,
|
|
Isprimary: req.Isprimary,
|
|
Status: req.Status,
|
|
}
|
|
if location.Status == "" {
|
|
location.Status = "Active"
|
|
}
|
|
|
|
if req.Isprimary {
|
|
db.DB.Model(&models.TenantLocation{}).Where("tenantid = ?", tenantID).Update("isprimary", false)
|
|
}
|
|
|
|
if err := db.DB.Create(&location).Error; err != nil {
|
|
return utils.Internal(c, "failed to create location")
|
|
}
|
|
return utils.Created(c, location)
|
|
}
|
|
|
|
func UpdateTenantLocation(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var location models.TenantLocation
|
|
if err := db.DB.First(&location, id).Error; err != nil {
|
|
return utils.NotFound(c, "tenant location not found")
|
|
}
|
|
// The location is addressed by its own id, so the tenant guard has to run
|
|
// against the row we loaded rather than a path parameter.
|
|
if !canAccessTenant(c, location.Tenantid) {
|
|
return utils.Forbidden(c, "not permitted for this tenant")
|
|
}
|
|
|
|
req := new(dto.TenantLocationCreateRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if req.Locationname != "" {
|
|
location.Locationname = req.Locationname
|
|
}
|
|
if req.Address != "" {
|
|
location.Address = req.Address
|
|
}
|
|
if req.City != "" {
|
|
location.City = req.City
|
|
}
|
|
if req.State != "" {
|
|
location.State = req.State
|
|
}
|
|
if req.Pincode != "" {
|
|
location.Pincode = req.Pincode
|
|
}
|
|
if req.Latitude != 0 {
|
|
location.Latitude = req.Latitude
|
|
}
|
|
if req.Longitude != 0 {
|
|
location.Longitude = req.Longitude
|
|
}
|
|
location.Isprimary = req.Isprimary
|
|
if req.Status != "" {
|
|
location.Status = req.Status
|
|
}
|
|
location.Updatedat = time.Now()
|
|
|
|
if req.Isprimary {
|
|
db.DB.Model(&models.TenantLocation{}).Where("tenantid = ?", location.Tenantid).Update("isprimary", false)
|
|
}
|
|
|
|
if err := db.DB.Save(&location).Error; err != nil {
|
|
return utils.Internal(c, "failed to update location")
|
|
}
|
|
return utils.OK(c, location)
|
|
}
|
|
|
|
// --------------------
|
|
// TENANT CUSTOMERS
|
|
// --------------------
|
|
|
|
func GetTenantCustomers(c *fiber.Ctx) error {
|
|
// The legacy customers table carries no tenant column and predates tenant
|
|
// attribution, so there is no way to prove any row belongs to a given
|
|
// client. Rather than hand a client the whole list, they get none of it.
|
|
if !isDoormileConsoleStaff(c) {
|
|
return utils.List(c, []models.Customer{}, 0)
|
|
}
|
|
var customers []models.Customer
|
|
if err := db.DB.Find(&customers).Error; err != nil {
|
|
return utils.Internal(c, "failed to fetch customers")
|
|
}
|
|
return utils.List(c, customers, int64(len(customers)))
|
|
}
|
|
|
|
// --------------------
|
|
// B2C APP CUSTOMERS
|
|
// --------------------
|
|
|
|
func GetAdminCustomers(c *fiber.Ctx) error {
|
|
pageno := max(1, c.QueryInt("pageno", 1))
|
|
pagesize := min(100, max(1, c.QueryInt("pagesize", 20)))
|
|
offset := (pageno - 1) * pagesize
|
|
keyword := c.Query("keyword")
|
|
|
|
query := db.DB.Model(&models.AppCustomer{})
|
|
if keyword != "" {
|
|
like := "%" + keyword + "%"
|
|
query = query.Where("firstname ILIKE ? OR lastname ILIKE ? OR phone ILIKE ?", like, like, like)
|
|
}
|
|
// A client sees only the customers they have actually delivered to, not
|
|
// Doormile's whole B2C address book. Doormile staff can ask for one
|
|
// client's customers with ?tenantid=.
|
|
tenantID, allowed := effectiveTenantID(c)
|
|
if !allowed {
|
|
return utils.Forbidden(c, "you can only view your own tenant")
|
|
}
|
|
if tenantID != 0 {
|
|
query = query.Where("appcustomerid IN (?)",
|
|
db.DB.Model(&models.PickupBooking{}).Select("appcustomerid").
|
|
Where("tenantid = ?", tenantID))
|
|
}
|
|
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return utils.Internal(c, "failed to count customers")
|
|
}
|
|
|
|
var customers []models.AppCustomer
|
|
if err := query.Offset(offset).Limit(pagesize).Find(&customers).Error; err != nil {
|
|
return utils.Internal(c, "failed to fetch customers")
|
|
}
|
|
|
|
ids := make([]int, 0, len(customers))
|
|
for _, cust := range customers {
|
|
ids = append(ids, cust.Appcustomerid)
|
|
}
|
|
|
|
bookingCounts := make(map[int]int64, len(ids))
|
|
if len(ids) > 0 {
|
|
type countRow struct {
|
|
Appcustomerid int
|
|
Cnt int64
|
|
}
|
|
var rows []countRow
|
|
if err := db.DB.Model(&models.PickupBooking{}).
|
|
Select("appcustomerid, count(*) as cnt").
|
|
Where("appcustomerid IN ?", ids).
|
|
Group("appcustomerid").
|
|
Scan(&rows).Error; err == nil {
|
|
for _, r := range rows {
|
|
bookingCounts[r.Appcustomerid] = r.Cnt
|
|
}
|
|
}
|
|
}
|
|
|
|
data := make([]fiber.Map, 0, len(customers))
|
|
for _, cust := range customers {
|
|
data = append(data, fiber.Map{
|
|
"appcustomerid": cust.Appcustomerid,
|
|
"name": strings.TrimSpace(cust.Firstname + " " + cust.Lastname),
|
|
"phone": cust.Phone,
|
|
"email": cust.Email,
|
|
"createdat": cust.Createdat,
|
|
"totalbookings": bookingCounts[cust.Appcustomerid],
|
|
})
|
|
}
|
|
|
|
pages := int(math.Ceil(float64(total) / float64(pagesize)))
|
|
|
|
return c.JSON(fiber.Map{
|
|
"success": true,
|
|
"data": data,
|
|
"total": total,
|
|
"pageno": pageno,
|
|
"pagesize": pagesize,
|
|
"pages": pages,
|
|
})
|
|
}
|
|
|
|
func UpdateAdminCustomer(c *fiber.Ctx) error {
|
|
id, err := strconv.Atoi(c.Params("id"))
|
|
if err != nil {
|
|
return utils.BadRequest(c, "invalid customer id")
|
|
}
|
|
|
|
var customer models.AppCustomer
|
|
if err := db.DB.First(&customer, id).Error; err != nil {
|
|
return utils.NotFound(c, "customer not found")
|
|
}
|
|
|
|
req := new(struct {
|
|
Name string `json:"name"`
|
|
Phone string `json:"phone"`
|
|
Email string `json:"email"`
|
|
})
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if req.Phone != "" {
|
|
if len(req.Phone) != 10 || strings.IndexFunc(req.Phone, func(r rune) bool { return r < '0' || r > '9' }) != -1 {
|
|
return utils.BadRequest(c, "phone must be 10 digits")
|
|
}
|
|
customer.Phone = req.Phone
|
|
}
|
|
|
|
if req.Name != "" {
|
|
name := strings.TrimSpace(req.Name)
|
|
parts := strings.SplitN(name, " ", 2)
|
|
customer.Firstname = parts[0]
|
|
if len(parts) > 1 {
|
|
customer.Lastname = parts[1]
|
|
} else {
|
|
customer.Lastname = ""
|
|
}
|
|
}
|
|
|
|
if req.Email != "" {
|
|
customer.Email = req.Email
|
|
}
|
|
|
|
customer.Updatedat = time.Now()
|
|
|
|
if err := db.DB.Model(&models.AppCustomer{}).Where("appcustomerid = ?", customer.Appcustomerid).
|
|
Updates(map[string]interface{}{
|
|
"firstname": customer.Firstname,
|
|
"lastname": customer.Lastname,
|
|
"phone": customer.Phone,
|
|
"email": customer.Email,
|
|
"updatedat": customer.Updatedat,
|
|
}).Error; err != nil {
|
|
return utils.Internal(c, "failed to update customer")
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"success": true,
|
|
"data": fiber.Map{
|
|
"appcustomerid": customer.Appcustomerid,
|
|
"name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
|
|
"phone": customer.Phone,
|
|
"email": customer.Email,
|
|
},
|
|
})
|
|
}
|
|
|
|
func CreateTenantCustomer(c *fiber.Ctx) error {
|
|
req := new(dto.TenantCustomerCreateRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
customer := models.Customer{
|
|
Firstname: req.Firstname,
|
|
Lastname: req.Lastname,
|
|
Contactno: req.Phone,
|
|
Email: req.Email,
|
|
Status: 1,
|
|
Createdat: time.Now(),
|
|
Updatedat: time.Now(),
|
|
}
|
|
|
|
if err := db.DB.Create(&customer).Error; err != nil {
|
|
return utils.Internal(c, "failed to create customer")
|
|
}
|
|
return utils.Created(c, customer)
|
|
}
|
|
|
|
func GetTenantCustomerDetails(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var customer models.Customer
|
|
if err := db.DB.First(&customer, id).Error; err != nil {
|
|
return utils.NotFound(c, "customer not found")
|
|
}
|
|
return utils.OK(c, customer)
|
|
}
|
|
|
|
func UpdateTenantCustomer(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var customer models.Customer
|
|
if err := db.DB.First(&customer, id).Error; err != nil {
|
|
return utils.NotFound(c, "customer not found")
|
|
}
|
|
|
|
req := new(dto.TenantCustomerCreateRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if req.Firstname != "" {
|
|
customer.Firstname = req.Firstname
|
|
}
|
|
if req.Lastname != "" {
|
|
customer.Lastname = req.Lastname
|
|
}
|
|
if req.Phone != "" {
|
|
customer.Contactno = req.Phone
|
|
}
|
|
if req.Email != "" {
|
|
customer.Email = req.Email
|
|
}
|
|
customer.Updatedat = time.Now()
|
|
|
|
if err := db.DB.Save(&customer).Error; err != nil {
|
|
return utils.Internal(c, "failed to update customer")
|
|
}
|
|
return utils.OK(c, customer)
|
|
}
|
|
|
|
func DeleteTenantCustomer(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var customer models.Customer
|
|
if err := db.DB.First(&customer, id).Error; err != nil {
|
|
return utils.NotFound(c, "customer not found")
|
|
}
|
|
|
|
if err := db.DB.Delete(&customer).Error; err != nil {
|
|
return utils.Internal(c, "failed to delete customer")
|
|
}
|
|
return utils.Message(c, "customer deleted successfully")
|
|
}
|
|
|
|
// --------------------
|
|
// PARTNERS CRUD
|
|
// --------------------
|
|
// PartnerInfo has no Deletedat column (unlike Hub/Vehicle), so delete here is
|
|
// a hard delete, matching DeleteTenant's pattern for the same reason.
|
|
|
|
func GetPartners(c *fiber.Ctx) error {
|
|
query := db.DB.Model(&models.PartnerInfo{})
|
|
|
|
if status := c.Query("status"); status != "" {
|
|
query = query.Where("status = ?", status)
|
|
}
|
|
if keyword := c.Query("keyword"); keyword != "" {
|
|
query = query.Where("partnername ILIKE ?", "%"+keyword+"%")
|
|
}
|
|
|
|
var partners []models.PartnerInfo
|
|
if err := query.Find(&partners).Error; err != nil {
|
|
return utils.Internal(c, "failed to fetch partners")
|
|
}
|
|
return utils.List(c, partners, int64(len(partners)))
|
|
}
|
|
|
|
func CreatePartner(c *fiber.Ctx) error {
|
|
req := new(dto.PartnerCreateRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if req.Partnername == "" {
|
|
return utils.BadRequest(c, "partnername is required")
|
|
}
|
|
|
|
partner := models.PartnerInfo{
|
|
Partnername: req.Partnername,
|
|
Partnertypeid: req.Partnertypeid,
|
|
Contactno: req.Contactno,
|
|
Status: req.Status,
|
|
}
|
|
if partner.Status == "" {
|
|
partner.Status = "Active"
|
|
}
|
|
|
|
if err := db.DB.Create(&partner).Error; err != nil {
|
|
return utils.Internal(c, "failed to create partner")
|
|
}
|
|
return utils.Created(c, partner)
|
|
}
|
|
|
|
func GetPartnerDetails(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var partner models.PartnerInfo
|
|
if err := db.DB.Where("partnerid = ?", id).First(&partner).Error; err != nil {
|
|
return utils.NotFound(c, "partner not found")
|
|
}
|
|
|
|
var vehicleCount int64
|
|
db.DB.Model(&models.Vehicle{}).Where("partnerid = ? AND deletedat IS NULL", id).Count(&vehicleCount)
|
|
|
|
return utils.OK(c, fiber.Map{
|
|
"partner": partner,
|
|
"vehicle_count": vehicleCount,
|
|
})
|
|
}
|
|
|
|
func UpdatePartner(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var partner models.PartnerInfo
|
|
if err := db.DB.Where("partnerid = ?", id).First(&partner).Error; err != nil {
|
|
return utils.NotFound(c, "partner not found")
|
|
}
|
|
|
|
req := new(dto.PartnerCreateRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if req.Partnername != "" {
|
|
partner.Partnername = req.Partnername
|
|
}
|
|
if req.Partnertypeid != 0 {
|
|
partner.Partnertypeid = req.Partnertypeid
|
|
}
|
|
if req.Contactno != "" {
|
|
partner.Contactno = req.Contactno
|
|
}
|
|
if req.Status != "" {
|
|
partner.Status = req.Status
|
|
}
|
|
partner.Updatedat = time.Now()
|
|
|
|
db.DB.Save(&partner)
|
|
return utils.OK(c, partner)
|
|
}
|
|
|
|
func DeletePartner(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var partner models.PartnerInfo
|
|
if err := db.DB.First(&partner, id).Error; err != nil {
|
|
return utils.NotFound(c, "partner not found")
|
|
}
|
|
|
|
var vehicleCount int64
|
|
db.DB.Model(&models.Vehicle{}).Where("partnerid = ? AND deletedat IS NULL", id).Count(&vehicleCount)
|
|
if vehicleCount > 0 {
|
|
return utils.BadRequest(c, "cannot delete a partner with vehicles still assigned to them")
|
|
}
|
|
|
|
if err := db.DB.Delete(&partner).Error; err != nil {
|
|
return utils.Internal(c, "failed to delete partner")
|
|
}
|
|
return utils.Message(c, "partner deleted successfully")
|
|
}
|
|
|
|
// --------------------
|
|
// HUBS CRUD
|
|
// --------------------
|
|
|
|
func GetHubs(c *fiber.Ctx) error {
|
|
var hubs []models.Hub
|
|
query := db.DB.Where("deletedat IS NULL")
|
|
|
|
if appLocationID := c.Query("applocationid"); appLocationID != "" {
|
|
query = query.Where("applocationid = ?", appLocationID)
|
|
}
|
|
if status := c.Query("status"); status != "" {
|
|
query = query.Where("status = ?", status)
|
|
}
|
|
if hubType := c.Query("hubtype"); hubType != "" {
|
|
query = query.Where("hubtype = ?", hubType)
|
|
}
|
|
|
|
if err := query.Find(&hubs).Error; err != nil {
|
|
return utils.Internal(c, "failed to fetch hubs")
|
|
}
|
|
return utils.List(c, hubs, int64(len(hubs)))
|
|
}
|
|
|
|
func CreateHub(c *fiber.Ctx) error {
|
|
req := new(dto.HubCreateRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
hub := models.Hub{
|
|
Hubname: req.Hubname,
|
|
Hubtype: req.Hubtype,
|
|
Applocationid: req.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)
|
|
}
|
|
|
|
func GetHubDetails(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var hub models.Hub
|
|
if err := db.DB.Where("hubid = ? AND deletedat IS NULL", id).First(&hub).Error; err != nil {
|
|
return utils.NotFound(c, "hub not found")
|
|
}
|
|
return utils.OK(c, hub)
|
|
}
|
|
|
|
func UpdateHub(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var hub models.Hub
|
|
if err := db.DB.Where("hubid = ? AND deletedat IS NULL", id).First(&hub).Error; err != nil {
|
|
return utils.NotFound(c, "hub not found")
|
|
}
|
|
|
|
req := new(dto.HubCreateRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if req.Hubname != "" {
|
|
hub.Hubname = req.Hubname
|
|
}
|
|
if req.Hubtype != "" {
|
|
hub.Hubtype = req.Hubtype
|
|
}
|
|
if req.Applocationid != 0 {
|
|
hub.Applocationid = req.Applocationid
|
|
}
|
|
if req.Address != "" {
|
|
hub.Address = req.Address
|
|
}
|
|
if req.Contactno != "" {
|
|
hub.Contactno = req.Contactno
|
|
}
|
|
if req.Latitude != 0 {
|
|
hub.Latitude = req.Latitude
|
|
}
|
|
if req.Longitude != 0 {
|
|
hub.Longitude = req.Longitude
|
|
}
|
|
if req.Pincode != "" {
|
|
hub.Pincode = req.Pincode
|
|
}
|
|
if req.Status != "" {
|
|
hub.Status = req.Status
|
|
}
|
|
hub.Updatedat = time.Now()
|
|
|
|
db.DB.Save(&hub)
|
|
return utils.OK(c, hub)
|
|
}
|
|
|
|
func DeleteHub(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var hub models.Hub
|
|
if err := db.DB.Where("hubid = ? AND deletedat IS NULL", id).First(&hub).Error; err != nil {
|
|
return utils.NotFound(c, "hub not found")
|
|
}
|
|
|
|
now := time.Now()
|
|
hub.Deletedat = &now
|
|
db.DB.Save(&hub)
|
|
return utils.Message(c, "hub deleted successfully")
|
|
}
|
|
|
|
// --------------------
|
|
// VEHICLES CRUD
|
|
// --------------------
|
|
|
|
func GetVehicles(c *fiber.Ctx) error {
|
|
var vehicles []models.Vehicle
|
|
if err := db.DB.Where("deletedat IS NULL").Find(&vehicles).Error; err != nil {
|
|
return utils.Internal(c, "failed to fetch vehicles")
|
|
}
|
|
return utils.List(c, vehicles, int64(len(vehicles)))
|
|
}
|
|
|
|
func CreateVehicle(c *fiber.Ctx) error {
|
|
req := new(dto.VehicleCreateRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
vehicle := models.Vehicle{
|
|
Vehicleno: req.Vehicleno,
|
|
Vehicletype: req.Vehicletype,
|
|
Maxweight: req.Maxweight,
|
|
Maxvolume: req.Maxvolume,
|
|
Partnerid: req.Partnerid,
|
|
Batterypercentage: req.Batterypercentage,
|
|
Status: req.Status,
|
|
}
|
|
if vehicle.Status == "" {
|
|
vehicle.Status = "Available"
|
|
}
|
|
|
|
if err := db.DB.Create(&vehicle).Error; err != nil {
|
|
return utils.Internal(c, "failed to create vehicle")
|
|
}
|
|
return utils.Created(c, vehicle)
|
|
}
|
|
|
|
func GetVehicleDetails(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var vehicle models.Vehicle
|
|
if err := db.DB.Where("vehicleid = ? AND deletedat IS NULL", id).First(&vehicle).Error; err != nil {
|
|
return utils.NotFound(c, "vehicle not found")
|
|
}
|
|
return utils.OK(c, vehicle)
|
|
}
|
|
|
|
func UpdateVehicle(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var vehicle models.Vehicle
|
|
if err := db.DB.Where("vehicleid = ? AND deletedat IS NULL", id).First(&vehicle).Error; err != nil {
|
|
return utils.NotFound(c, "vehicle not found")
|
|
}
|
|
|
|
req := new(dto.VehicleCreateRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if req.Vehicleno != "" {
|
|
vehicle.Vehicleno = req.Vehicleno
|
|
}
|
|
if req.Vehicletype != "" {
|
|
vehicle.Vehicletype = req.Vehicletype
|
|
}
|
|
if req.Maxweight != 0 {
|
|
vehicle.Maxweight = req.Maxweight
|
|
}
|
|
if req.Maxvolume != 0 {
|
|
vehicle.Maxvolume = req.Maxvolume
|
|
}
|
|
vehicle.Partnerid = req.Partnerid
|
|
if req.Batterypercentage != 0 {
|
|
vehicle.Batterypercentage = req.Batterypercentage
|
|
}
|
|
if req.Status != "" {
|
|
vehicle.Status = req.Status
|
|
}
|
|
vehicle.Updatedat = time.Now()
|
|
|
|
if err := db.DB.Save(&vehicle).Error; err != nil {
|
|
return utils.Internal(c, "failed to update vehicle")
|
|
}
|
|
return utils.OK(c, vehicle)
|
|
}
|
|
|
|
func DeleteVehicle(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var vehicle models.Vehicle
|
|
if err := db.DB.Where("vehicleid = ? AND deletedat IS NULL", id).First(&vehicle).Error; err != nil {
|
|
return utils.NotFound(c, "vehicle not found")
|
|
}
|
|
|
|
now := time.Now()
|
|
vehicle.Deletedat = &now
|
|
db.DB.Save(&vehicle)
|
|
return utils.Message(c, "vehicle deleted successfully")
|
|
}
|
|
|
|
// --------------------
|
|
// MILERS MANAGEMENT
|
|
// --------------------
|
|
|
|
func GetMilers(c *fiber.Ctx) error {
|
|
tenantID, allowed := effectiveTenantID(c)
|
|
if !allowed {
|
|
return utils.Forbidden(c, "you can only view your own tenant")
|
|
}
|
|
|
|
var profiles []models.MilerProfile
|
|
query := db.DB.Model(&models.MilerProfile{})
|
|
if appLocStr := c.Query("applocationid"); appLocStr != "" {
|
|
query = query.Where("applocationid = ?", appLocStr)
|
|
}
|
|
if hubID := c.Query("hubid"); hubID != "" {
|
|
query = query.Where("hubid = ?", hubID)
|
|
}
|
|
// Riders belong to a client through their appusers row, so the fleet filter
|
|
// is a subquery on that rather than a column here. A client login is pinned
|
|
// to its own tenant; Doormile staff pass ?tenantid= to see one client's
|
|
// fleet, or omit it for the whole roster.
|
|
if tenantID != 0 {
|
|
ids := milerUserIDsForTenant(tenantID)
|
|
if len(ids) == 0 {
|
|
return utils.List(c, []models.MilerProfile{}, 0)
|
|
}
|
|
query = query.Where("userid IN ?", ids)
|
|
}
|
|
if err := query.Order("displayname").Find(&profiles).Error; err != nil {
|
|
return utils.Internal(c, "failed to fetch milers")
|
|
}
|
|
return utils.List(c, profiles, int64(len(profiles)))
|
|
}
|
|
|
|
func CreateMiler(c *fiber.Ctx) error {
|
|
req := new(dto.MilerCreateRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
passHash, _ := utils.HashPassword(req.Password)
|
|
|
|
tx := db.DB.Begin()
|
|
|
|
appLocID := req.Applocationid
|
|
if appLocID == 0 {
|
|
appLocID = 1
|
|
}
|
|
|
|
// A client login may only create riders under its own tenant.
|
|
tenantID := req.Tenantid
|
|
if own := consoleTenantID(c); own != 0 {
|
|
tenantID = own
|
|
}
|
|
|
|
// Configid must match what LoginMiler looks up by — it queries
|
|
// "contactno = ? AND configid = ?" defaulting to 1001. Left unset, AppUser's
|
|
// column default of 1 applies and the rider can never log in, which is what
|
|
// happened to every miler created through this endpoint until now.
|
|
configID := req.Configid
|
|
if configID == 0 {
|
|
configID = 1001
|
|
}
|
|
|
|
user := models.AppUser{
|
|
Authname: req.Authname,
|
|
Email: req.Email,
|
|
Contactno: req.Contactno,
|
|
Password: passHash,
|
|
Roleid: 5, // Miler
|
|
Status: "Active",
|
|
Applocationid: appLocID,
|
|
Tenantid: tenantID,
|
|
Hubid: req.Hubid,
|
|
Configid: configID,
|
|
}
|
|
|
|
if err := tx.Create(&user).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to create miler account")
|
|
}
|
|
|
|
profile := models.MilerProfile{
|
|
Userid: user.Userid,
|
|
Displayname: req.Displayname,
|
|
Phone: req.Contactno,
|
|
Defaultvehicletype: req.Defaultvehicletype,
|
|
Availabilitystatus: constants.MilerOffline,
|
|
Rating: 5.00,
|
|
Applocationid: appLocID,
|
|
Hubid: req.Hubid,
|
|
}
|
|
|
|
if err := tx.Create(&profile).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to create miler profile")
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return utils.Internal(c, "failed to create miler")
|
|
}
|
|
return utils.Created(c, profile)
|
|
}
|
|
|
|
func GetMilerDetails(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
// GetMilers scopes the roster to the caller's own fleet, but reading one
|
|
// rider by id did not — a client login could walk the whole network's riders
|
|
// by incrementing the id.
|
|
profile, ok := findMilerForConsole(c, id)
|
|
if !ok {
|
|
return utils.NotFound(c, "miler not found")
|
|
}
|
|
return utils.OK(c, profile)
|
|
}
|
|
|
|
// AdminNotifyMiler lets console staff push a one-off notification to a
|
|
// specific miler directly (not tied to a booking, unlike InternalNotify
|
|
// which is machine-to-machine and booking-scoped). :id is the milerprofileid,
|
|
// matching every other /admin/milers/:id route.
|
|
func AdminNotifyMiler(c *fiber.Ctx) error {
|
|
id, err := strconv.Atoi(c.Params("id"))
|
|
if err != nil {
|
|
return utils.BadRequest(c, "invalid miler ID")
|
|
}
|
|
|
|
var req struct {
|
|
Title string `json:"title"`
|
|
Message string `json:"message"`
|
|
Data map[string]string `json:"data"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
if req.Title == "" || req.Message == "" {
|
|
return utils.BadRequest(c, "title and message are required")
|
|
}
|
|
|
|
profile, ok := findMilerForConsole(c, id)
|
|
if !ok {
|
|
return utils.NotFound(c, "miler not found")
|
|
}
|
|
|
|
if profile.Devicetoken == "" {
|
|
return utils.BadRequest(c, "this miler has no registered device to notify")
|
|
}
|
|
|
|
if err := notify.SendToDevice(profile.Devicetoken, req.Title, req.Message, req.Data); err != nil {
|
|
return utils.Internal(c, "failed to send notification")
|
|
}
|
|
|
|
return utils.Message(c, "notification sent")
|
|
}
|
|
|
|
func UpdateMiler(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
profile, ok := findMilerForConsole(c, id)
|
|
if !ok {
|
|
return utils.NotFound(c, "miler not found")
|
|
}
|
|
|
|
type MilerUpdate struct {
|
|
Displayname string `json:"displayname"`
|
|
Defaultvehicletype string `json:"defaultvehicletype"`
|
|
Hubid *int `json:"hubid"`
|
|
}
|
|
|
|
req := new(MilerUpdate)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if req.Displayname != "" {
|
|
profile.Displayname = req.Displayname
|
|
}
|
|
if req.Defaultvehicletype != "" {
|
|
profile.Defaultvehicletype = req.Defaultvehicletype
|
|
}
|
|
if req.Hubid != nil {
|
|
profile.Hubid = req.Hubid
|
|
}
|
|
profile.Updatedat = time.Now()
|
|
|
|
if err := db.DB.Save(profile).Error; err != nil {
|
|
return utils.Internal(c, "failed to update miler")
|
|
}
|
|
return utils.OK(c, profile)
|
|
}
|
|
|
|
func BlockMiler(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
profile, ok := findMilerForConsole(c, id)
|
|
if !ok {
|
|
return utils.NotFound(c, "miler not found")
|
|
}
|
|
|
|
tx := db.DB.Begin()
|
|
|
|
profile.Availabilitystatus = constants.MilerBlocked
|
|
profile.Updatedat = time.Now()
|
|
if err := tx.Save(profile).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to block miler profile")
|
|
}
|
|
|
|
if err := tx.Model(&models.AppUser{}).Where("userid = ?", profile.Userid).
|
|
Update("status", "Blocked").Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to block miler account")
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return utils.Internal(c, "failed to block miler")
|
|
}
|
|
return utils.Message(c, "miler blocked successfully")
|
|
}
|
|
|
|
func AssignMilerVehicle(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
|
|
type VehicleAssign struct {
|
|
Vehicleid int `json:"vehicleid"`
|
|
}
|
|
req := new(VehicleAssign)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
profile, ok := findMilerForConsole(c, id)
|
|
if !ok {
|
|
return utils.NotFound(c, "miler not found")
|
|
}
|
|
|
|
// A vehicle can only be handed to a rider the caller owns, and only from
|
|
// their own fleet — otherwise a client could park another client's van
|
|
// against their rider.
|
|
var vehicle models.Vehicle
|
|
if err := db.DB.Where("vehicleid = ?", req.Vehicleid).First(&vehicle).Error; err != nil {
|
|
return utils.NotFound(c, "vehicle not found")
|
|
}
|
|
|
|
profile.Vehicleid = &req.Vehicleid
|
|
profile.Updatedat = time.Now()
|
|
if err := db.DB.Save(profile).Error; err != nil {
|
|
return utils.Internal(c, "failed to assign vehicle")
|
|
}
|
|
|
|
return utils.OK(c, profile)
|
|
}
|
|
|
|
// --------------------
|
|
// BOOKINGS MANAGEMENT
|
|
// --------------------
|
|
|
|
func GetAdminBookings(c *fiber.Ctx) error {
|
|
pageno := max(1, c.QueryInt("pageno", 1))
|
|
pagesize := min(100, max(1, c.QueryInt("pagesize", 20)))
|
|
offset := (pageno - 1) * pagesize
|
|
|
|
tenantID, allowed := effectiveTenantID(c)
|
|
if !allowed {
|
|
return utils.Forbidden(c, "you can only view your own tenant")
|
|
}
|
|
query := scopeToTenant(db.DB.Model(&models.PickupBooking{}), "tenantid", tenantID)
|
|
if status := c.Query("status"); status != "" {
|
|
query = query.Where("status = ?", status)
|
|
}
|
|
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return utils.Internal(c, "failed to count bookings")
|
|
}
|
|
|
|
var bookings []models.PickupBooking
|
|
if err := query.Preload("Parcels").Preload("ServiceOptions").
|
|
Offset(offset).Limit(pagesize).Find(&bookings).Error; err != nil {
|
|
return utils.Internal(c, "failed to fetch bookings")
|
|
}
|
|
|
|
pages := int(math.Ceil(float64(total) / float64(pagesize)))
|
|
|
|
return c.JSON(fiber.Map{
|
|
"success": true,
|
|
"data": bookings,
|
|
"total": total,
|
|
"pageno": pageno,
|
|
"pagesize": pagesize,
|
|
"pages": pages,
|
|
})
|
|
}
|
|
|
|
// AdminBookingRequest is the express-console booking payload, shared by CreateExpressBooking
|
|
// (one booking) and AdminBulkCreateBookings (many) — was previously a type
|
|
// local to CreateExpressBooking, promoted to package level so both can use it.
|
|
type AdminBookingRequest struct {
|
|
Tenantid int `json:"tenantid"`
|
|
Appcustomerid int `json:"appcustomerid"`
|
|
CustomerPhone string `json:"customer_phone"`
|
|
CustomerName string `json:"customer_name"`
|
|
// Tenantlocationid names the client site the parcel is collected from — a
|
|
// DailyGrubs kitchen, for instance. Optional, but supplying it lets the
|
|
// address/pincode/coordinates be filled from the stored site instead of
|
|
// retyped, and is what per-site reporting groups by. When it is omitted the
|
|
// site is inferred from the pickup coordinates or address.
|
|
Tenantlocationid *int `json:"tenantlocationid"`
|
|
// Pickuplocationid is accepted only as an alias for the field above, for
|
|
// callers written against the earlier docs. It is never stored as-is: the
|
|
// column of that name foreign-keys to appcustomerlocations, not to a
|
|
// client's sites.
|
|
Pickuplocationid *int `json:"pickuplocationid"`
|
|
Pickupaddress string `json:"pickupaddress"`
|
|
Pickuppincode string `json:"pickuppincode"`
|
|
Pickuplatitude float64 `json:"pickuplatitude"`
|
|
Pickuplongitude float64 `json:"pickuplongitude"`
|
|
Deliveryaddress string `json:"deliveryaddress"`
|
|
Deliverypincode string `json:"deliverypincode"`
|
|
Deliverycity string `json:"deliverycity"`
|
|
Deliverylatitude float64 `json:"deliverylatitude"`
|
|
Deliverylongitude float64 `json:"deliverylongitude"`
|
|
Providercompany string `json:"providercompany"`
|
|
Providerlocation string `json:"providerlocation"`
|
|
Notes string `json:"notes"`
|
|
ServiceOption string `json:"service_option"`
|
|
Finalprice float64 `json:"finalprice"`
|
|
Insuranceamount float64 `json:"insuranceamount"`
|
|
Preferredpickupfrom *time.Time `json:"preferredpickupfrom"`
|
|
Preferredpickupto *time.Time `json:"preferredpickupto"`
|
|
Parcels []dto.ParcelRequest `json:"parcels"`
|
|
}
|
|
|
|
// expressBookingValidationError marks a createExpressBooking failure as a bad
|
|
// request (missing/invalid input) rather than a server-side failure, so
|
|
// CreateExpressBooking can still return the right HTTP status after the
|
|
// validation logic moved into the shared helper below.
|
|
type expressBookingValidationError struct{ msg string }
|
|
|
|
func (e *expressBookingValidationError) Error() string { return e.msg }
|
|
|
|
// createExpressBooking holds the actual booking-creation logic, shared by
|
|
// CreateExpressBooking (one booking, used by the "New Booking" form) and
|
|
// AdminBulkCreateBookings (many, used by CSV/bulk import). Takes no
|
|
// *fiber.Ctx — the original function never touched c after BodyParser, so
|
|
// both callers can use this identically.
|
|
func createExpressBooking(req AdminBookingRequest) (*models.PickupBooking, error) {
|
|
if len(req.Parcels) == 0 {
|
|
return nil, &expressBookingValidationError{"at least one parcel is required"}
|
|
}
|
|
if req.Tenantid == 0 {
|
|
return nil, &expressBookingValidationError{"tenantid is required for express-console bookings"}
|
|
}
|
|
var tenant models.Tenant
|
|
if err := db.DB.Where("tenantid = ?", req.Tenantid).First(&tenant).Error; err != nil {
|
|
return nil, &expressBookingValidationError{"tenantid does not match a known tenant"}
|
|
}
|
|
|
|
// A named pickup site fills in whatever the caller left blank, so the console
|
|
// can send a kitchen id instead of restating its address every time. It must
|
|
// belong to the booking's tenant — otherwise one client could book against
|
|
// another client's site.
|
|
//
|
|
// `pickuplocationid` is accepted as an alias here purely for callers written
|
|
// against the earlier documentation. It is the wrong column: it foreign-keys
|
|
// to appcustomerlocations, so a tenantlocations id in it fails the insert.
|
|
// Both names resolve to Tenantlocationid.
|
|
siteID := req.Tenantlocationid
|
|
if siteID == nil {
|
|
siteID = req.Pickuplocationid
|
|
}
|
|
req.Pickuplocationid = nil
|
|
|
|
if siteID != nil {
|
|
var loc models.TenantLocation
|
|
if err := db.DB.Where("tenantlocationid = ?", *siteID).First(&loc).Error; err != nil {
|
|
return nil, &expressBookingValidationError{"tenantlocationid does not match a known location"}
|
|
}
|
|
if loc.Tenantid != req.Tenantid {
|
|
return nil, &expressBookingValidationError{"tenantlocationid does not belong to this tenant"}
|
|
}
|
|
req.Tenantlocationid = siteID
|
|
if req.Pickupaddress == "" {
|
|
req.Pickupaddress = loc.Address
|
|
}
|
|
if req.Pickuppincode == "" {
|
|
req.Pickuppincode = loc.Pincode
|
|
}
|
|
if req.Pickuplatitude == 0 && req.Pickuplongitude == 0 {
|
|
req.Pickuplatitude, req.Pickuplongitude = loc.Latitude, loc.Longitude
|
|
}
|
|
}
|
|
|
|
// Checked after the location fill-in, so a caller supplying only a kitchen
|
|
// id is not rejected for an address it never needed to send.
|
|
if req.Pickupaddress == "" || req.Pickuppincode == "" {
|
|
return nil, &expressBookingValidationError{"pickup address and pincode are required"}
|
|
}
|
|
|
|
// If the caller did not name a site, try to recognise it from where the
|
|
// pickup actually is. Without this the field stays null — as it did on every
|
|
// booking in the system — and per-site reporting has nothing to group by,
|
|
// because the console sends a kitchen's address rather than its id.
|
|
if req.Tenantlocationid == nil {
|
|
req.Tenantlocationid = matchTenantLocation(req.Tenantid, req.Pickupaddress, req.Pickuplatitude, req.Pickuplongitude)
|
|
}
|
|
|
|
tx := db.DB.Begin()
|
|
|
|
customerID := req.Appcustomerid
|
|
if customerID == 0 && req.CustomerPhone != "" {
|
|
// Try to find customer by phone
|
|
var customer models.AppCustomer
|
|
if err := tx.Where("phone = ?", req.CustomerPhone).First(&customer).Error; err == nil {
|
|
customerID = customer.Appcustomerid
|
|
} else {
|
|
// Create dummy customer
|
|
newCustomer := models.AppCustomer{
|
|
Firstname: req.CustomerName,
|
|
Phone: req.CustomerPhone,
|
|
Status: "Active",
|
|
Configid: 1001,
|
|
}
|
|
if req.CustomerName == "" {
|
|
newCustomer.Firstname = "Guest"
|
|
}
|
|
if err := tx.Create(&newCustomer).Error; err != nil {
|
|
tx.Rollback()
|
|
return nil, fmt.Errorf("failed to create customer record")
|
|
}
|
|
customerID = newCustomer.Appcustomerid
|
|
}
|
|
}
|
|
|
|
tenantID := req.Tenantid
|
|
booking := models.PickupBooking{
|
|
Bookingno: generateBookingNo(),
|
|
Tenantid: &tenantID,
|
|
Appcustomerid: customerID,
|
|
Pickuplocationid: req.Pickuplocationid,
|
|
Tenantlocationid: req.Tenantlocationid,
|
|
Pickupaddress: req.Pickupaddress,
|
|
Pickuppincode: req.Pickuppincode,
|
|
Pickuplatitude: req.Pickuplatitude,
|
|
Pickuplongitude: req.Pickuplongitude,
|
|
Deliveryaddress: req.Deliveryaddress,
|
|
Deliverypincode: req.Deliverypincode,
|
|
Deliverycity: req.Deliverycity,
|
|
Deliverylatitude: req.Deliverylatitude,
|
|
Deliverylongitude: req.Deliverylongitude,
|
|
Bookingsource: "CRM_Console",
|
|
Providercompany: req.Providercompany,
|
|
Providerlocation: req.Providerlocation,
|
|
Notes: req.Notes,
|
|
Status: constants.BookingPendingPickup,
|
|
Preferredpickupfrom: req.Preferredpickupfrom,
|
|
Preferredpickupto: req.Preferredpickupto,
|
|
}
|
|
|
|
if err := tx.Create(&booking).Error; err != nil {
|
|
tx.Rollback()
|
|
return nil, fmt.Errorf("failed to create booking")
|
|
}
|
|
|
|
var totalWeight float64
|
|
var totalVolume float64
|
|
var requiresLargeVehicle bool
|
|
for _, p := range req.Parcels {
|
|
volumetric := calculateVolumetricWeight(p.Length, p.Width, p.Height)
|
|
totalWeight += math.Max(p.Weight, volumetric)
|
|
totalVolume += p.Length * p.Width * p.Height
|
|
|
|
parcel := models.BookingParcel{
|
|
Bookingid: booking.Bookingid,
|
|
Itemcategory: p.Itemcategory,
|
|
Itemdescription: p.Itemdescription,
|
|
Declaredvalue: p.Declaredvalue,
|
|
Weight: p.Weight,
|
|
Length: p.Length,
|
|
Width: p.Width,
|
|
Height: p.Height,
|
|
Isfragile: p.Isfragile,
|
|
Needsinsurance: p.Needsinsurance,
|
|
Requireslargevehicle: p.Requireslargevehicle,
|
|
}
|
|
|
|
if p.Requireslargevehicle {
|
|
requiresLargeVehicle = true
|
|
}
|
|
|
|
if p.Needsinsurance {
|
|
parcel.Insuranceamount = p.Declaredvalue * 0.01
|
|
}
|
|
|
|
// If explicit insurance amount is provided in the request, apply it to the first parcel
|
|
if req.Insuranceamount > 0 && totalWeight == math.Max(p.Weight, calculateVolumetricWeight(p.Length, p.Width, p.Height)) {
|
|
parcel.Insuranceamount = req.Insuranceamount
|
|
parcel.Needsinsurance = true
|
|
}
|
|
|
|
if err := tx.Create(&parcel).Error; err != nil {
|
|
tx.Rollback()
|
|
return nil, fmt.Errorf("failed to save parcel details")
|
|
}
|
|
}
|
|
|
|
var distance float64
|
|
if booking.Deliverylatitude != 0 && booking.Deliverylongitude != 0 {
|
|
distance = calculateDistance(booking.Pickuplatitude, booking.Pickuplongitude, booking.Deliverylatitude, booking.Deliverylongitude)
|
|
}
|
|
|
|
var pricing models.Pricing
|
|
err := tx.Where("status = ? AND ? BETWEEN effectivefrom AND effectiveto", "Active", time.Now()).Order("priority DESC").First(&pricing).Error
|
|
|
|
var estimatedPrice float64
|
|
var pricingID *int
|
|
if err == nil {
|
|
pricingID = &pricing.Pricingid
|
|
kmExtra := math.Max(0, distance-pricing.Basedistance)
|
|
kgExtra := math.Max(0, totalWeight-pricing.Baseweight)
|
|
estimatedPrice = pricing.Baseprice + (kmExtra * pricing.Priceperkm) + (kgExtra * pricing.Priceperkg) + pricing.Handlingcharges
|
|
} else {
|
|
estimatedPrice = 50.0 + (distance * 5.0) + (totalWeight * 10.0)
|
|
}
|
|
|
|
if req.Finalprice > 0 {
|
|
estimatedPrice = req.Finalprice
|
|
}
|
|
|
|
serviceType := req.ServiceOption
|
|
if serviceType == "" {
|
|
serviceType = "Normal"
|
|
}
|
|
|
|
if serviceType == "Fast" {
|
|
estimatedPrice *= 1.25
|
|
} else if serviceType == "Superfast" {
|
|
estimatedPrice *= 1.5
|
|
}
|
|
|
|
now := time.Now()
|
|
estDelivery := now.Add(24 * time.Hour)
|
|
slaDue := now.Add(36 * time.Hour)
|
|
if serviceType == "Fast" {
|
|
estDelivery = now.Add(12 * time.Hour)
|
|
slaDue = now.Add(18 * time.Hour)
|
|
} else if serviceType == "Superfast" {
|
|
estDelivery = now.Add(6 * time.Hour)
|
|
slaDue = now.Add(9 * time.Hour)
|
|
}
|
|
|
|
srvOption := models.BookingServiceOption{
|
|
Bookingid: booking.Bookingid,
|
|
Servicetype: serviceType,
|
|
Estimatedprice: estimatedPrice,
|
|
Estimateddeliveryat: &estDelivery,
|
|
Sladueat: &slaDue,
|
|
Pricingid: pricingID,
|
|
}
|
|
|
|
if err := tx.Create(&srvOption).Error; err != nil {
|
|
tx.Rollback()
|
|
return nil, fmt.Errorf("failed to save service option")
|
|
}
|
|
|
|
if requiresLargeVehicle || totalWeight > 20.0 {
|
|
reqVeh := models.BookingVehicleRequirement{
|
|
Bookingid: booking.Bookingid,
|
|
Requiredvehicletype: "truck",
|
|
Reason: "Oversized package / heavy weight",
|
|
Status: "Required",
|
|
}
|
|
if err := tx.Create(&reqVeh).Error; err != nil {
|
|
tx.Rollback()
|
|
return nil, fmt.Errorf("failed to save vehicle requirement")
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return nil, fmt.Errorf("failed to create booking")
|
|
}
|
|
|
|
go assignment.AssignCRMMiler(booking.Bookingid)
|
|
|
|
if db.Js != nil {
|
|
payload := map[string]interface{}{
|
|
"booking_id": booking.Bookingid,
|
|
"booking_no": booking.Bookingno,
|
|
"customer_id": booking.Appcustomerid,
|
|
"pickup_address": booking.Pickupaddress,
|
|
"pickup_pincode": booking.Pickuppincode,
|
|
"status": constants.BookingPendingPickup,
|
|
"created_at": time.Now().UnixMilli(),
|
|
}
|
|
if data, err := json.Marshal(payload); err == nil {
|
|
db.Js.Publish("api.v1.bookings.create", data)
|
|
}
|
|
}
|
|
|
|
db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, booking.Bookingid)
|
|
|
|
return &booking, nil
|
|
}
|
|
|
|
func CreateExpressBooking(c *fiber.Ctx) error {
|
|
req := new(AdminBookingRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
// A client login may only book under its own tenant. Left unchecked, the
|
|
// tenantid is caller-supplied, so a client could attribute bookings — and
|
|
// their cost — to another client.
|
|
if own := consoleTenantID(c); own != 0 {
|
|
req.Tenantid = own
|
|
}
|
|
|
|
booking, err := createExpressBooking(*req)
|
|
if err != nil {
|
|
if _, ok := err.(*expressBookingValidationError); ok {
|
|
return utils.BadRequest(c, err.Error())
|
|
}
|
|
return utils.Internal(c, err.Error())
|
|
}
|
|
|
|
return utils.Created(c, booking)
|
|
}
|
|
|
|
// AdminBulkCreateBookings creates several express-console bookings in one call — the
|
|
// console's CSV/bulk-import flow. Each item is processed independently, same
|
|
// per-item-result shape as AdminBulkCancelBookings, so one bad row (missing
|
|
// address, unknown tenantid) doesn't block the rest of the batch.
|
|
func AdminBulkCreateBookings(c *fiber.Ctx) error {
|
|
var req struct {
|
|
Bookings []AdminBookingRequest `json:"bookings"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
if len(req.Bookings) == 0 {
|
|
return utils.BadRequest(c, "bookings is required and must not be empty")
|
|
}
|
|
if len(req.Bookings) > 200 {
|
|
return utils.BadRequest(c, "maximum 200 bookings per bulk request")
|
|
}
|
|
|
|
type result struct {
|
|
Index int `json:"index"`
|
|
Success bool `json:"success"`
|
|
Bookingid int `json:"bookingid,omitempty"`
|
|
Bookingno string `json:"bookingno,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
results := make([]result, 0, len(req.Bookings))
|
|
|
|
ownTenant := consoleTenantID(c)
|
|
|
|
for i, item := range req.Bookings {
|
|
// Same tenant pin as the single-booking path — a bulk import must not
|
|
// be a way around it.
|
|
if ownTenant != 0 {
|
|
item.Tenantid = ownTenant
|
|
}
|
|
booking, err := createExpressBooking(item)
|
|
if err != nil {
|
|
results = append(results, result{Index: i, Success: false, Error: err.Error()})
|
|
continue
|
|
}
|
|
results = append(results, result{Index: i, Success: true, Bookingid: booking.Bookingid, Bookingno: booking.Bookingno})
|
|
}
|
|
|
|
return utils.OK(c, fiber.Map{"results": results})
|
|
}
|
|
|
|
func GetAdminBookingDetails(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var booking models.PickupBooking
|
|
q := scopeToOwnTenant(c, db.DB.Preload("Parcels").Preload("ServiceOptions").Preload("Payments"), "tenantid")
|
|
if err := q.First(&booking, id).Error; err != nil {
|
|
return utils.NotFound(c, "booking not found")
|
|
}
|
|
return utils.OK(c, booking)
|
|
}
|
|
|
|
func AdminAssignMiler(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
if !canAccessBooking(c, id) {
|
|
return utils.NotFound(c, "booking not found")
|
|
}
|
|
|
|
type MilerAssign struct {
|
|
Mileruserid int `json:"mileruserid"`
|
|
}
|
|
req := new(MilerAssign)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
adminUserID := c.Locals("userid").(int)
|
|
|
|
booking, err := AssignMilerToBooking(id, req.Mileruserid, &adminUserID)
|
|
if err != nil {
|
|
return utils.NotFound(c, "booking not found")
|
|
}
|
|
|
|
return utils.OK(c, booking)
|
|
}
|
|
|
|
func AdminAssignVehicle(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
if !canAccessBooking(c, id) {
|
|
return utils.NotFound(c, "booking not found")
|
|
}
|
|
|
|
type VehicleAssign struct {
|
|
Vehicleid int `json:"vehicleid"`
|
|
Driveruserid int `json:"driveruserid"`
|
|
}
|
|
req := new(VehicleAssign)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
var reqVeh models.BookingVehicleRequirement
|
|
if err := db.DB.Where("bookingid = ? AND status = ?", id, "Required").First(&reqVeh).Error; err != nil {
|
|
return utils.NotFound(c, "no active vehicle requirement found for this booking")
|
|
}
|
|
|
|
reqVeh.Assignedvehicleid = &req.Vehicleid
|
|
reqVeh.Assigneddriveruserid = &req.Driveruserid
|
|
reqVeh.Status = "Assigned"
|
|
reqVeh.Updatedat = time.Now()
|
|
db.DB.Save(&reqVeh)
|
|
|
|
return utils.OK(c, reqVeh)
|
|
}
|
|
|
|
func AdminUpdateBookingStatus(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
if !canAccessBooking(c, id) {
|
|
return utils.NotFound(c, "booking not found")
|
|
}
|
|
|
|
type StatusUpdate struct {
|
|
Status string `json:"status"`
|
|
}
|
|
req := new(StatusUpdate)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if req.Status == "" {
|
|
return utils.BadRequest(c, "status is required")
|
|
}
|
|
|
|
var booking models.PickupBooking
|
|
if err := db.DB.First(&booking, id).Error; err != nil {
|
|
return utils.NotFound(c, "booking not found")
|
|
}
|
|
|
|
booking.Status = req.Status
|
|
booking.Updatedat = time.Now()
|
|
db.DB.Save(&booking)
|
|
|
|
return utils.OK(c, booking)
|
|
}
|
|
|
|
// AdminCancelBooking cancels a booking on behalf of operations, frees up the
|
|
// assigned miler (if any), and notifies downstream systems (NATS + customer push).
|
|
// A booking that has already been converted to a consignment (shipped) or is
|
|
// already cancelled cannot be cancelled again.
|
|
func AdminCancelBooking(c *fiber.Ctx) error {
|
|
id, err := strconv.Atoi(c.Params("id"))
|
|
if err != nil {
|
|
return utils.BadRequest(c, "invalid booking ID")
|
|
}
|
|
if !canAccessBooking(c, id) {
|
|
return utils.NotFound(c, "booking not found")
|
|
}
|
|
|
|
var booking models.PickupBooking
|
|
if err := db.DB.First(&booking, id).Error; err != nil {
|
|
return utils.NotFound(c, "booking not found")
|
|
}
|
|
|
|
if booking.Status == constants.BookingConvertedConsignment || booking.Status == constants.BookingCancelled {
|
|
return utils.BadRequest(c, "cannot cancel a delivered or already cancelled booking")
|
|
}
|
|
|
|
booking.Status = constants.BookingCancelled
|
|
booking.Updatedat = time.Now()
|
|
if err := db.DB.Save(&booking).Error; err != nil {
|
|
return utils.Internal(c, "failed to cancel booking")
|
|
}
|
|
|
|
if booking.Assignedmileruserid != nil {
|
|
db.DB.Model(&models.MilerProfile{}).
|
|
Where("userid = ?", *booking.Assignedmileruserid).
|
|
Update("availabilitystatus", constants.MilerAvailable)
|
|
}
|
|
|
|
if db.Js != nil {
|
|
payload := map[string]interface{}{
|
|
"bookingid": booking.Bookingid,
|
|
"reason": "admin_cancelled",
|
|
}
|
|
if data, err := json.Marshal(payload); err == nil {
|
|
if _, err := db.Js.Publish("booking.cancelled", data); err != nil {
|
|
utils.Warn("AdminCancelBooking: NATS publish failed", "booking_id", booking.Bookingid, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
var customer models.AppCustomer
|
|
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
|
|
if err := notify.SendToDevice(customer.Devicetoken, "Booking Cancelled", "Your booking has been cancelled by operations", nil); err != nil {
|
|
utils.Warn("AdminCancelBooking: failed to notify customer", "booking_id", booking.Bookingid, "error", err)
|
|
}
|
|
}
|
|
|
|
return utils.Message(c, "booking cancelled")
|
|
}
|
|
|
|
// AdminBulkCancelBookings cancels several bookings in one call — the console's
|
|
// multi-select "cancel selected" action. Each id is processed independently
|
|
// so one bad id (already shipped, already cancelled, not found) doesn't block
|
|
// the rest; the response reports success/failure per id rather than failing
|
|
// the whole batch on the first error.
|
|
func AdminBulkCancelBookings(c *fiber.Ctx) error {
|
|
var req struct {
|
|
Bookingids []int `json:"bookingids"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
if len(req.Bookingids) == 0 {
|
|
return utils.BadRequest(c, "bookingids is required and must not be empty")
|
|
}
|
|
|
|
type result struct {
|
|
Bookingid int `json:"bookingid"`
|
|
Success bool `json:"success"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
results := make([]result, 0, len(req.Bookingids))
|
|
|
|
ownTenant := consoleTenantID(c)
|
|
|
|
for _, id := range req.Bookingids {
|
|
var booking models.PickupBooking
|
|
if err := db.DB.First(&booking, id).Error; err != nil {
|
|
results = append(results, result{Bookingid: id, Success: false, Error: "booking not found"})
|
|
continue
|
|
}
|
|
|
|
// Reported as not-found rather than forbidden, so a client can't probe
|
|
// which booking ids belong to other tenants.
|
|
if ownTenant != 0 && (booking.Tenantid == nil || *booking.Tenantid != ownTenant) {
|
|
results = append(results, result{Bookingid: id, Success: false, Error: "booking not found"})
|
|
continue
|
|
}
|
|
|
|
if booking.Status == constants.BookingConvertedConsignment || booking.Status == constants.BookingCancelled {
|
|
results = append(results, result{Bookingid: id, Success: false, Error: "cannot cancel a delivered or already cancelled booking"})
|
|
continue
|
|
}
|
|
|
|
booking.Status = constants.BookingCancelled
|
|
booking.Updatedat = time.Now()
|
|
if err := db.DB.Save(&booking).Error; err != nil {
|
|
results = append(results, result{Bookingid: id, Success: false, Error: "failed to save"})
|
|
continue
|
|
}
|
|
|
|
if booking.Assignedmileruserid != nil {
|
|
db.DB.Model(&models.MilerProfile{}).
|
|
Where("userid = ?", *booking.Assignedmileruserid).
|
|
Update("availabilitystatus", constants.MilerAvailable)
|
|
}
|
|
|
|
if db.Js != nil {
|
|
payload := map[string]interface{}{"bookingid": booking.Bookingid, "reason": "admin_bulk_cancelled"}
|
|
if data, err := json.Marshal(payload); err == nil {
|
|
if _, err := db.Js.Publish("booking.cancelled", data); err != nil {
|
|
utils.Warn("AdminBulkCancelBookings: NATS publish failed", "booking_id", booking.Bookingid, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
var customer models.AppCustomer
|
|
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
|
|
if err := notify.SendToDevice(customer.Devicetoken, "Booking Cancelled", "Your booking has been cancelled by operations", nil); err != nil {
|
|
utils.Warn("AdminBulkCancelBookings: failed to notify customer", "booking_id", booking.Bookingid, "error", err)
|
|
}
|
|
}
|
|
|
|
results = append(results, result{Bookingid: id, Success: true})
|
|
}
|
|
|
|
return utils.OK(c, fiber.Map{"results": results})
|
|
}
|
|
|
|
// --------------------
|
|
// CONSIGNMENTS
|
|
// --------------------
|
|
|
|
func GetAdminConsignments(c *fiber.Ctx) error {
|
|
page := utils.ParsePage(c)
|
|
|
|
tenantID, allowed := effectiveTenantID(c)
|
|
if !allowed {
|
|
return utils.Forbidden(c, "you can only view your own tenant")
|
|
}
|
|
|
|
var total int64
|
|
if err := scopeToTenant(db.DB.Model(&models.Consignment{}), "tenantid", tenantID).
|
|
Count(&total).Error; err != nil {
|
|
return utils.Internal(c, "failed to count consignments")
|
|
}
|
|
|
|
var list []models.Consignment
|
|
if err := page.Apply(scopeToTenant(db.DB.Model(&models.Consignment{}), "tenantid", tenantID)).
|
|
Find(&list).Error; err != nil {
|
|
return utils.Internal(c, "failed to fetch consignments")
|
|
}
|
|
return utils.Paginated(c, list, total, page)
|
|
}
|
|
|
|
func GetAdminConsignmentDetails(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var csg models.Consignment
|
|
if err := scopeToOwnTenant(c, db.DB, "tenantid").First(&csg, id).Error; err != nil {
|
|
return utils.NotFound(c, "consignment not found")
|
|
}
|
|
return utils.OK(c, csg)
|
|
}
|
|
|
|
func GetAdminConsignmentTracking(c *fiber.Ctx) error {
|
|
trackingNo := c.Params("trackingno")
|
|
var consignment models.Consignment
|
|
if err := scopeToOwnTenant(c, db.DB, "tenantid").
|
|
Where("trackingno = ?", trackingNo).First(&consignment).Error; err != nil {
|
|
return utils.NotFound(c, "consignment not found")
|
|
}
|
|
var history []models.ConsignmentHistory
|
|
db.DB.Where("consignmentid = ?", consignment.Consignmentid).Order("createdat DESC").Find(&history)
|
|
return utils.OK(c, fiber.Map{"consignment": consignment, "history": history})
|
|
}
|
|
|
|
func AdminUpdateConsignmentStatus(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
|
|
// Addressed by id, so the ownership check has to happen before the write —
|
|
// otherwise a client could move another client's parcel through the network.
|
|
if own := consoleTenantID(c); own != 0 {
|
|
var owner models.Consignment
|
|
if err := db.DB.Select("consignmentid", "tenantid").First(&owner, id).Error; err != nil {
|
|
return utils.NotFound(c, "consignment not found")
|
|
}
|
|
if owner.Tenantid != own {
|
|
return utils.NotFound(c, "consignment not found")
|
|
}
|
|
}
|
|
|
|
type StatusUpdate struct {
|
|
Status string `json:"status"`
|
|
Remarks string `json:"remarks"`
|
|
}
|
|
req := new(StatusUpdate)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if req.Status == "" {
|
|
return utils.BadRequest(c, "status is required")
|
|
}
|
|
|
|
tx := db.DB.Begin()
|
|
|
|
var consignment models.Consignment
|
|
if err := tx.First(&consignment, id).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.NotFound(c, "consignment not found")
|
|
}
|
|
|
|
consignment.Status = req.Status
|
|
consignment.Updatedat = time.Now()
|
|
if err := tx.Save(&consignment).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to update consignment status")
|
|
}
|
|
|
|
adminUserID := c.Locals("userid").(int)
|
|
|
|
history := models.ConsignmentHistory{
|
|
Consignmentid: consignment.Consignmentid,
|
|
Userid: &adminUserID,
|
|
Eventstatus: req.Status,
|
|
Remarks: req.Remarks,
|
|
}
|
|
if err := tx.Create(&history).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to record consignment history")
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return utils.Internal(c, "failed to update consignment")
|
|
}
|
|
return utils.OK(c, consignment)
|
|
}
|
|
|
|
// --------------------
|
|
// TRIPSHEETS (MANIFEST)
|
|
// --------------------
|
|
|
|
func GetTripsheets(c *fiber.Ctx) error {
|
|
// A tripsheet is a Doormile vehicle run and routinely carries several
|
|
// clients' parcels on the same manifest, so there is no honest way to show
|
|
// one to a single client. Doormile staff only.
|
|
if !isDoormileConsoleStaff(c) {
|
|
return utils.List(c, []models.Tripsheet{}, 0)
|
|
}
|
|
|
|
page := utils.ParsePage(c)
|
|
|
|
var total int64
|
|
if err := db.DB.Model(&models.Tripsheet{}).Where("deletedat IS NULL").Count(&total).Error; err != nil {
|
|
return utils.Internal(c, "failed to count tripsheets")
|
|
}
|
|
|
|
var list []models.Tripsheet
|
|
if err := page.Apply(db.DB.Where("deletedat IS NULL")).Find(&list).Error; err != nil {
|
|
return utils.Internal(c, "failed to fetch tripsheets")
|
|
}
|
|
return utils.Paginated(c, list, total, page)
|
|
}
|
|
|
|
func CreateTripsheet(c *fiber.Ctx) error {
|
|
req := new(dto.TripsheetCreateRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
adminUserID := c.Locals("userid").(int)
|
|
|
|
tripsheet := models.Tripsheet{
|
|
Tripsheetno: generateTripsheetNo(),
|
|
Sourcehubid: req.Sourcehubid,
|
|
Destinationhubid: req.Destinationhubid,
|
|
Vehicleid: req.Vehicleid,
|
|
Driveruserid: req.Driveruserid,
|
|
Status: constants.TripsheetDraft,
|
|
Createdby: adminUserID,
|
|
Updatedby: adminUserID,
|
|
}
|
|
|
|
if err := db.DB.Create(&tripsheet).Error; err != nil {
|
|
return utils.Internal(c, "failed to create tripsheet")
|
|
}
|
|
return utils.Created(c, tripsheet)
|
|
}
|
|
|
|
func GetTripsheetDetails(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var tripsheet models.Tripsheet
|
|
if err := db.DB.Where("tripsheetid = ? AND deletedat IS NULL", id).First(&tripsheet).Error; err != nil {
|
|
return utils.NotFound(c, "tripsheet not found")
|
|
}
|
|
var items []models.TripsheetItem
|
|
db.DB.Where("tripsheetid = ?", id).Find(&items)
|
|
return utils.OK(c, fiber.Map{"tripsheet": tripsheet, "items": items})
|
|
}
|
|
|
|
func AddTripsheetItem(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
req := new(dto.TripsheetItemAddRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
adminUserID := c.Locals("userid").(int)
|
|
|
|
item := models.TripsheetItem{
|
|
Tripsheetid: id,
|
|
Consignmentid: req.Consignmentid,
|
|
Scanstatus: constants.ScanPending,
|
|
Createdby: adminUserID,
|
|
Updatedby: adminUserID,
|
|
}
|
|
|
|
if err := db.DB.Create(&item).Error; err != nil {
|
|
return utils.Internal(c, "failed to add item to tripsheet")
|
|
}
|
|
return utils.Created(c, item)
|
|
}
|
|
|
|
func DeleteTripsheetItem(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
itemID, _ := strconv.Atoi(c.Params("itemid"))
|
|
|
|
var item models.TripsheetItem
|
|
if err := db.DB.Where("tripsheetid = ? AND tripsheetitemid = ?", id, itemID).First(&item).Error; err != nil {
|
|
return utils.NotFound(c, "item not found on this tripsheet")
|
|
}
|
|
|
|
db.DB.Delete(&item)
|
|
return utils.Message(c, "item removed from tripsheet")
|
|
}
|
|
|
|
func DispatchTripsheet(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
|
|
tx := db.DB.Begin()
|
|
var tripsheet models.Tripsheet
|
|
if err := tx.Where("tripsheetid = ?", id).First(&tripsheet).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.NotFound(c, "tripsheet not found")
|
|
}
|
|
|
|
now := time.Now()
|
|
tripsheet.Status = constants.TripsheetDispatched
|
|
tripsheet.Dispatchtime = &now
|
|
tripsheet.Updatedat = now
|
|
if err := tx.Save(&tripsheet).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to dispatch tripsheet")
|
|
}
|
|
|
|
// Fetch all loaded items
|
|
var items []models.TripsheetItem
|
|
if err := tx.Where("tripsheetid = ?", id).Find(&items).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to load tripsheet items")
|
|
}
|
|
|
|
adminUserID := c.Locals("userid").(int)
|
|
|
|
for _, item := range items {
|
|
// Update item scanning
|
|
if err := tx.Model(&item).Updates(map[string]interface{}{
|
|
"scanstatus": constants.ScanLoaded,
|
|
"scannedat": &now,
|
|
}).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to update tripsheet item scan status")
|
|
}
|
|
|
|
// Update consignment status to In_Transit
|
|
if err := tx.Model(&models.Consignment{}).Where("consignmentid = ?", item.Consignmentid).Updates(map[string]interface{}{
|
|
"status": constants.ConsignmentInTransit,
|
|
"updatedat": now,
|
|
}).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to update consignment status")
|
|
}
|
|
|
|
// Log history
|
|
history := models.ConsignmentHistory{
|
|
Consignmentid: item.Consignmentid,
|
|
Tripsheetid: &id,
|
|
Userid: &adminUserID,
|
|
Eventstatus: constants.ConsignmentInTransit,
|
|
Remarks: fmt.Sprintf("Consignment dispatched on Tripsheet %s", tripsheet.Tripsheetno),
|
|
}
|
|
if err := tx.Create(&history).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to record consignment history")
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return utils.Internal(c, "failed to dispatch tripsheet")
|
|
}
|
|
return utils.OK(c, tripsheet)
|
|
}
|
|
|
|
func ArriveTripsheet(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
|
|
tx := db.DB.Begin()
|
|
var tripsheet models.Tripsheet
|
|
if err := tx.Where("tripsheetid = ?", id).First(&tripsheet).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.NotFound(c, "tripsheet not found")
|
|
}
|
|
|
|
now := time.Now()
|
|
tripsheet.Status = constants.TripsheetArrived
|
|
tripsheet.Arrivaltime = &now
|
|
tripsheet.Updatedat = now
|
|
if err := tx.Save(&tripsheet).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to mark tripsheet arrived")
|
|
}
|
|
|
|
// Fetch items
|
|
var items []models.TripsheetItem
|
|
if err := tx.Where("tripsheetid = ?", id).Find(&items).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to load tripsheet items")
|
|
}
|
|
|
|
adminUserID := c.Locals("userid").(int)
|
|
|
|
for _, item := range items {
|
|
if err := tx.Model(&item).Updates(map[string]interface{}{
|
|
"scanstatus": constants.ScanUnloaded,
|
|
"scannedat": &now,
|
|
}).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to update tripsheet item scan status")
|
|
}
|
|
|
|
// Update consignment status back to Inwarded_at_Hub at destination
|
|
if err := tx.Model(&models.Consignment{}).Where("consignmentid = ?", item.Consignmentid).Updates(map[string]interface{}{
|
|
"status": constants.ConsignmentInwardedAtHub,
|
|
"currenthubid": tripsheet.Destinationhubid,
|
|
"updatedat": now,
|
|
}).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to update consignment status")
|
|
}
|
|
|
|
// Log history
|
|
history := models.ConsignmentHistory{
|
|
Consignmentid: item.Consignmentid,
|
|
Tripsheetid: &id,
|
|
Hubid: &tripsheet.Destinationhubid,
|
|
Userid: &adminUserID,
|
|
Eventstatus: constants.ConsignmentInwardedAtHub,
|
|
Remarks: fmt.Sprintf("Consignment arrived at Hub on Tripsheet %s", tripsheet.Tripsheetno),
|
|
}
|
|
if err := tx.Create(&history).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to record consignment history")
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return utils.Internal(c, "failed to mark tripsheet arrived")
|
|
}
|
|
return utils.OK(c, tripsheet)
|
|
}
|
|
|
|
// --------------------
|
|
// PRICING CRUD
|
|
// --------------------
|
|
|
|
func GetPricing(c *fiber.Ctx) error {
|
|
var pricing []models.Pricing
|
|
// Rates are commercially sensitive: one client must never see what another
|
|
// is charged.
|
|
if err := scopeToOwnTenant(c, db.DB, "tenantid").
|
|
Where("deletedat IS NULL").Find(&pricing).Error; err != nil {
|
|
return utils.Internal(c, "failed to fetch pricing schedules")
|
|
}
|
|
return utils.List(c, pricing, int64(len(pricing)))
|
|
}
|
|
|
|
func CreatePricing(c *fiber.Ctx) error {
|
|
req := new(dto.PricingCreateRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
adminUserID := c.Locals("userid").(int)
|
|
|
|
pricing := models.Pricing{
|
|
Tenantid: req.Tenantid,
|
|
Applocationid: req.Applocationid,
|
|
Vehicletype: req.Vehicletype,
|
|
Baseprice: req.Baseprice,
|
|
Baseweight: req.Baseweight,
|
|
Priceperkg: req.Priceperkg,
|
|
Basedistance: req.Basedistance,
|
|
Priceperkm: req.Priceperkm,
|
|
Handlingcharges: req.Handlingcharges,
|
|
Effectivefrom: req.Effectivefrom,
|
|
Effectiveto: req.Effectiveto,
|
|
Currency: req.Currency,
|
|
Priority: req.Priority,
|
|
Status: req.Status,
|
|
Createdby: adminUserID,
|
|
Updatedby: adminUserID,
|
|
}
|
|
if pricing.Currency == "" {
|
|
pricing.Currency = "INR"
|
|
}
|
|
if pricing.Status == "" {
|
|
pricing.Status = "Active"
|
|
}
|
|
|
|
if err := db.DB.Create(&pricing).Error; err != nil {
|
|
return utils.Internal(c, "failed to create pricing schedule")
|
|
}
|
|
return utils.Created(c, pricing)
|
|
}
|
|
|
|
func UpdatePricing(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var pricing models.Pricing
|
|
if err := db.DB.Where("pricingid = ? AND deletedat IS NULL", id).First(&pricing).Error; err != nil {
|
|
return utils.NotFound(c, "pricing schedule not found")
|
|
}
|
|
|
|
req := new(dto.PricingCreateRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if req.Baseprice != 0 {
|
|
pricing.Baseprice = req.Baseprice
|
|
}
|
|
if req.Baseweight != 0 {
|
|
pricing.Baseweight = req.Baseweight
|
|
}
|
|
if req.Priceperkg != 0 {
|
|
pricing.Priceperkg = req.Priceperkg
|
|
}
|
|
if req.Basedistance != 0 {
|
|
pricing.Basedistance = req.Basedistance
|
|
}
|
|
if req.Priceperkm != 0 {
|
|
pricing.Priceperkm = req.Priceperkm
|
|
}
|
|
pricing.Handlingcharges = req.Handlingcharges
|
|
if !req.Effectivefrom.IsZero() {
|
|
pricing.Effectivefrom = req.Effectivefrom
|
|
}
|
|
if !req.Effectiveto.IsZero() {
|
|
pricing.Effectiveto = req.Effectiveto
|
|
}
|
|
if req.Status != "" {
|
|
pricing.Status = req.Status
|
|
}
|
|
pricing.Updatedat = time.Now()
|
|
pricing.Updatedby = c.Locals("userid").(int)
|
|
|
|
if err := db.DB.Save(&pricing).Error; err != nil {
|
|
return utils.Internal(c, "failed to update pricing schedule")
|
|
}
|
|
return utils.OK(c, pricing)
|
|
}
|
|
|
|
func DeletePricing(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var pricing models.Pricing
|
|
if err := db.DB.Where("pricingid = ? AND deletedat IS NULL", id).First(&pricing).Error; err != nil {
|
|
return utils.NotFound(c, "pricing schedule not found")
|
|
}
|
|
|
|
now := time.Now()
|
|
pricing.Deletedat = &now
|
|
db.DB.Save(&pricing)
|
|
return utils.Message(c, "pricing schedule deleted successfully")
|
|
}
|
|
|
|
func GetPricingQuoteSimulate(c *fiber.Ctx) error {
|
|
req := new(dto.PricingQuoteRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
distance := calculateDistance(req.Pickuplatitude, req.Pickuplongitude, req.Deliverylatitude, req.Deliverylongitude)
|
|
|
|
var totalWeight float64
|
|
for _, p := range req.Parcels {
|
|
volumetric := calculateVolumetricWeight(p.Length, p.Width, p.Height)
|
|
totalWeight += math.Max(p.Weight, volumetric)
|
|
}
|
|
|
|
var pricing models.Pricing
|
|
err := db.DB.Where("status = ? AND ? BETWEEN effectivefrom AND effectiveto", "Active", time.Now()).Order("priority DESC").First(&pricing).Error
|
|
|
|
var baseQuote float64
|
|
var pricingIDPtr *int
|
|
if err == nil {
|
|
pricingIDPtr = &pricing.Pricingid
|
|
kmExtra := math.Max(0, distance-pricing.Basedistance)
|
|
kgExtra := math.Max(0, totalWeight-pricing.Baseweight)
|
|
baseQuote = pricing.Baseprice + (kmExtra * pricing.Priceperkm) + (kgExtra * pricing.Priceperkg) + pricing.Handlingcharges
|
|
} else {
|
|
// Fallback simple pricing engine
|
|
baseQuote = 50.0 + (distance * 5.0) + (totalWeight * 10.0)
|
|
}
|
|
|
|
return utils.OK(c, fiber.Map{
|
|
"distance_km": distance,
|
|
"chargeable_weight": totalWeight,
|
|
"base_quote": baseQuote,
|
|
"pricing_id": pricingIDPtr,
|
|
})
|
|
}
|
|
|
|
// --------------------
|
|
// EXCEPTIONS
|
|
// --------------------
|
|
|
|
func GetExceptions(c *fiber.Ctx) error {
|
|
page := utils.ParsePage(c)
|
|
|
|
// An exception belongs to whoever owns the parcel it was raised against.
|
|
var total int64
|
|
if err := scopeViaConsignments(c, db.DB.Model(&models.ConsignmentException{}), "consignmentid").
|
|
Where("deletedat IS NULL").Count(&total).Error; err != nil {
|
|
return utils.Internal(c, "failed to count exceptions")
|
|
}
|
|
|
|
var list []models.ConsignmentException
|
|
if err := page.Apply(scopeViaConsignments(c, db.DB, "consignmentid").Where("deletedat IS NULL")).
|
|
Find(&list).Error; err != nil {
|
|
return utils.Internal(c, "failed to fetch exceptions")
|
|
}
|
|
return utils.Paginated(c, list, total, page)
|
|
}
|
|
|
|
func CreateException(c *fiber.Ctx) error {
|
|
req := new(dto.ExceptionCreateRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
adminUserID := c.Locals("userid").(int)
|
|
|
|
exception := models.ConsignmentException{
|
|
Consignmentid: req.Consignmentid,
|
|
Tripsheetid: req.Tripsheetid,
|
|
Hubid: req.Hubid,
|
|
Reportedbyuserid: &adminUserID,
|
|
Exceptiontype: req.Exceptiontype,
|
|
Severity: req.Severity,
|
|
Description: req.Description,
|
|
Status: constants.ExceptionOpen,
|
|
Createdby: adminUserID,
|
|
Updatedby: adminUserID,
|
|
}
|
|
if exception.Severity == "" {
|
|
exception.Severity = "Medium"
|
|
}
|
|
|
|
tx := db.DB.Begin()
|
|
|
|
if err := tx.Create(&exception).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to record exception")
|
|
}
|
|
|
|
// Update consignment status to Missing or Damaged if critical exception type matches
|
|
var cStatus string
|
|
if req.Exceptiontype == constants.ExceptionLost {
|
|
cStatus = constants.ConsignmentMissing
|
|
} else if req.Exceptiontype == constants.ExceptionDamaged {
|
|
cStatus = constants.ConsignmentDamaged
|
|
}
|
|
|
|
if cStatus != "" {
|
|
if err := tx.Model(&models.Consignment{}).Where("consignmentid = ?", req.Consignmentid).
|
|
Update("status", cStatus).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to update consignment status")
|
|
}
|
|
|
|
// Log history
|
|
history := models.ConsignmentHistory{
|
|
Consignmentid: req.Consignmentid,
|
|
Tripsheetid: req.Tripsheetid,
|
|
Hubid: req.Hubid,
|
|
Userid: &adminUserID,
|
|
Eventstatus: cStatus,
|
|
Remarks: fmt.Sprintf("Exception reported: %s. Description: %s", req.Exceptiontype, req.Description),
|
|
}
|
|
if err := tx.Create(&history).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to record consignment history")
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return utils.Internal(c, "failed to record exception")
|
|
}
|
|
return utils.Created(c, exception)
|
|
}
|
|
|
|
func GetExceptionDetails(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
var exception models.ConsignmentException
|
|
if err := db.DB.Where("exceptionid = ? AND deletedat IS NULL", id).First(&exception).Error; err != nil {
|
|
return utils.NotFound(c, "exception not found")
|
|
}
|
|
return utils.OK(c, exception)
|
|
}
|
|
|
|
func ResolveException(c *fiber.Ctx) error {
|
|
id, _ := strconv.Atoi(c.Params("id"))
|
|
|
|
req := new(dto.ExceptionResolveRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
adminUserID := c.Locals("userid").(int)
|
|
|
|
var exception models.ConsignmentException
|
|
if err := db.DB.Where("exceptionid = ? AND deletedat IS NULL", id).First(&exception).Error; err != nil {
|
|
return utils.NotFound(c, "exception not found")
|
|
}
|
|
|
|
exception.Resolution = req.Resolution
|
|
exception.Status = req.Status
|
|
exception.Updatedby = adminUserID
|
|
exception.Updatedat = time.Now()
|
|
|
|
if err := db.DB.Save(&exception).Error; err != nil {
|
|
return utils.Internal(c, "failed to resolve exception")
|
|
}
|
|
return utils.OK(c, exception)
|
|
}
|
|
|
|
func CreateUserRedis(c *fiber.Ctx) error {
|
|
ctx := context.Background()
|
|
|
|
var userData models.CachedUser
|
|
if err := c.BodyParser(&userData); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if userData.UserID == 0 || userData.Username == "" {
|
|
return utils.BadRequest(c, "userid and username are required")
|
|
}
|
|
|
|
if db.Rdb == nil {
|
|
return utils.Internal(c, "cache service unavailable")
|
|
}
|
|
|
|
jsonData, err := json.Marshal(userData)
|
|
if err != nil {
|
|
return utils.Internal(c, "failed to serialize user data")
|
|
}
|
|
|
|
userKey := fmt.Sprintf("user:%d", userData.UserID)
|
|
if err := db.Rdb.Set(ctx, userKey, jsonData, 0).Err(); err != nil {
|
|
return utils.Internal(c, "failed to store user in cache")
|
|
}
|
|
|
|
db.Rdb.ZAdd(ctx, "user", redis.Z{
|
|
Score: float64(time.Now().Unix()),
|
|
Member: fmt.Sprintf("%d", userData.UserID),
|
|
})
|
|
|
|
return utils.Created(c, userData)
|
|
}
|
|
|
|
func GetUserRedis(c *fiber.Ctx) error {
|
|
ctx := context.Background()
|
|
|
|
if db.Rdb == nil {
|
|
return utils.Internal(c, "cache service unavailable")
|
|
}
|
|
|
|
userIDStr := c.Query("userid")
|
|
|
|
if userIDStr != "" {
|
|
userID, err := strconv.Atoi(userIDStr)
|
|
if err != nil || userID == 0 {
|
|
return utils.BadRequest(c, "invalid userid parameter")
|
|
}
|
|
|
|
userKey := fmt.Sprintf("user:%d", userID)
|
|
userData, err := db.Rdb.Get(ctx, userKey).Result()
|
|
if err != nil {
|
|
return utils.NotFound(c, "user not found in cache")
|
|
}
|
|
|
|
var user map[string]interface{}
|
|
json.Unmarshal([]byte(userData), &user)
|
|
|
|
return utils.OK(c, user)
|
|
}
|
|
|
|
pageStr := c.Query("page")
|
|
pageSizeStr := c.Query("pagesize")
|
|
page, _ := strconv.Atoi(pageStr)
|
|
pageSize, _ := strconv.Atoi(pageSizeStr)
|
|
|
|
var start, end int64
|
|
if page > 0 && pageSize > 0 {
|
|
offset := (page - 1) * pageSize
|
|
start = int64(offset)
|
|
end = int64(offset + pageSize - 1)
|
|
} else {
|
|
start = 0
|
|
end = -1
|
|
}
|
|
|
|
userIDs, err := db.Rdb.ZRevRange(ctx, "user", start, end).Result()
|
|
if err != nil {
|
|
return utils.Internal(c, "failed to fetch user list")
|
|
}
|
|
|
|
if len(userIDs) == 0 {
|
|
return utils.List(c, []interface{}{}, 0)
|
|
}
|
|
|
|
var keys []string
|
|
for _, id := range userIDs {
|
|
keys = append(keys, fmt.Sprintf("user:%s", id))
|
|
}
|
|
|
|
values, err := db.Rdb.MGet(ctx, keys...).Result()
|
|
if err != nil {
|
|
return utils.Internal(c, "failed to retrieve user data")
|
|
}
|
|
|
|
var users []map[string]interface{}
|
|
for _, val := range values {
|
|
if val == nil {
|
|
continue
|
|
}
|
|
var user map[string]interface{}
|
|
json.Unmarshal([]byte(val.(string)), &user)
|
|
users = append(users, user)
|
|
}
|
|
|
|
return utils.List(c, users, int64(len(users)))
|
|
}
|
|
|
|
func UpdateUserRedis(c *fiber.Ctx) error {
|
|
ctx := context.Background()
|
|
|
|
userID, err := strconv.Atoi(c.Params("userid"))
|
|
if err != nil || userID == 0 {
|
|
return utils.BadRequest(c, "invalid userid parameter")
|
|
}
|
|
|
|
var userData map[string]interface{}
|
|
if err := c.BodyParser(&userData); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if db.Rdb == nil {
|
|
return utils.Internal(c, "cache service unavailable")
|
|
}
|
|
|
|
userKey := fmt.Sprintf("user:%d", userID)
|
|
exists, err := db.Rdb.Exists(ctx, userKey).Result()
|
|
if err != nil || exists == 0 {
|
|
return utils.NotFound(c, "user not found in cache")
|
|
}
|
|
|
|
userData["userid"] = userID
|
|
jsonData, _ := json.Marshal(userData)
|
|
|
|
if err := db.Rdb.Set(ctx, userKey, jsonData, 0).Err(); err != nil {
|
|
return utils.Internal(c, "failed to update user in cache")
|
|
}
|
|
|
|
return utils.OK(c, userData)
|
|
}
|
|
|
|
func DeleteUserRedis(c *fiber.Ctx) error {
|
|
ctx := context.Background()
|
|
|
|
userID, err := strconv.Atoi(c.Params("userid"))
|
|
if err != nil || userID == 0 {
|
|
return utils.BadRequest(c, "invalid userid parameter")
|
|
}
|
|
|
|
if db.Rdb == nil {
|
|
return utils.Internal(c, "cache service unavailable")
|
|
}
|
|
|
|
userKey := fmt.Sprintf("user:%d", userID)
|
|
db.Rdb.Del(ctx, userKey)
|
|
db.Rdb.ZRem(ctx, "user", fmt.Sprintf("%d", userID))
|
|
|
|
return utils.Message(c, "user removed from cache successfully")
|
|
}
|
|
|
|
func GetAllUsersRedis(c *fiber.Ctx) error {
|
|
ctx := context.Background()
|
|
|
|
if db.Rdb == nil {
|
|
return utils.Internal(c, "cache service unavailable")
|
|
}
|
|
|
|
userIDs, err := db.Rdb.ZRange(ctx, "user", 0, -1).Result()
|
|
if err != nil {
|
|
return utils.Internal(c, "failed to fetch user index")
|
|
}
|
|
|
|
var users []map[string]interface{}
|
|
for _, idStr := range userIDs {
|
|
userKey := fmt.Sprintf("user:%s", idStr)
|
|
userData, err := db.Rdb.Get(ctx, userKey).Result()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var user map[string]interface{}
|
|
json.Unmarshal([]byte(userData), &user)
|
|
users = append(users, user)
|
|
}
|
|
|
|
return utils.List(c, users, int64(len(users)))
|
|
}
|
|
|
|
func GetAdminProfile(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("userid").(int)
|
|
if !ok {
|
|
return utils.Unauthorized(c, "authentication required")
|
|
}
|
|
|
|
var user models.AppUser
|
|
if err := db.DB.Where("userid = ?", userID).First(&user).Error; err != nil {
|
|
return utils.NotFound(c, "user not found")
|
|
}
|
|
|
|
return utils.OK(c, user)
|
|
}
|
|
|
|
// AdminChangePassword lets a logged-in admin console user change their own
|
|
// password. Unlike ResetCustomerPin/ResetMilerPin — open, phone-only reset
|
|
// endpoints matching what those two apps already do — this requires an
|
|
// active session and the current password. Admin accounts touch tenant,
|
|
// pricing, and financial data, so an open "reset by email" endpoint here
|
|
// would be a much bigger blast radius than a customer or miler PIN reset;
|
|
// intentionally not mirroring that pattern for this one.
|
|
func AdminChangePassword(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("userid").(int)
|
|
if !ok {
|
|
return utils.Unauthorized(c, "authentication required")
|
|
}
|
|
|
|
var req struct {
|
|
CurrentPassword string `json:"current_password"`
|
|
NewPassword string `json:"new_password"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
if req.CurrentPassword == "" || req.NewPassword == "" {
|
|
return utils.BadRequest(c, "current_password and new_password are required")
|
|
}
|
|
if len(req.NewPassword) < 8 {
|
|
return utils.BadRequest(c, "new_password must be at least 8 characters")
|
|
}
|
|
|
|
var user models.AppUser
|
|
if err := db.DB.Where("userid = ?", userID).First(&user).Error; err != nil {
|
|
return utils.NotFound(c, "user not found")
|
|
}
|
|
|
|
var auth models.DoormileAuth
|
|
if err := db.DB.Where("email = ?", user.Email).First(&auth).Error; err != nil {
|
|
return utils.NotFound(c, "admin credentials not found for this account")
|
|
}
|
|
|
|
if !utils.CheckPasswordHash(req.CurrentPassword, auth.PasswordHash) {
|
|
return utils.Unauthorized(c, "current password is incorrect")
|
|
}
|
|
|
|
newHash, err := utils.HashPassword(req.NewPassword)
|
|
if err != nil {
|
|
return utils.Internal(c, "failed to process password change")
|
|
}
|
|
|
|
auth.PasswordHash = newHash
|
|
if err := db.DB.Save(&auth).Error; err != nil {
|
|
return utils.Internal(c, "failed to update password")
|
|
}
|
|
|
|
return utils.Message(c, "password changed successfully")
|
|
}
|
|
|
|
// InternalNotify sends FCM push notifications on behalf of the Python agent system.
|
|
// The caller specifies target = "customer", "miler", or "both".
|
|
// Auth: X-Internal-Key header (see InternalKeyAuth middleware).
|
|
//
|
|
// POST /api/v1/internal/notify
|
|
func InternalNotify(c *fiber.Ctx) error {
|
|
type req struct {
|
|
BookingID int `json:"booking_id"`
|
|
Target string `json:"target"`
|
|
Title string `json:"title"`
|
|
Message string `json:"message"`
|
|
Data map[string]string `json:"data"`
|
|
}
|
|
|
|
body := new(req)
|
|
if err := c.BodyParser(body); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
if body.BookingID == 0 {
|
|
return utils.BadRequest(c, "booking_id is required")
|
|
}
|
|
if body.Target != "customer" && body.Target != "miler" && body.Target != "both" {
|
|
return utils.BadRequest(c, "target must be customer, miler, or both")
|
|
}
|
|
if body.Title == "" || body.Message == "" {
|
|
return utils.BadRequest(c, "title and message are required")
|
|
}
|
|
|
|
var booking models.PickupBooking
|
|
if err := db.DB.First(&booking, body.BookingID).Error; err != nil {
|
|
return utils.NotFound(c, "booking not found")
|
|
}
|
|
|
|
data := body.Data
|
|
if data == nil {
|
|
data = map[string]string{}
|
|
}
|
|
data["booking_id"] = strconv.Itoa(booking.Bookingid)
|
|
|
|
sent := make([]string, 0, 2)
|
|
|
|
if body.Target == "customer" || body.Target == "both" {
|
|
var customer models.AppCustomer
|
|
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
|
|
if err := notify.SendToDevice(customer.Devicetoken, body.Title, body.Message, data); err != nil {
|
|
utils.Warn("InternalNotify: failed to notify customer", "booking_id", booking.Bookingid, "error", err)
|
|
} else {
|
|
sent = append(sent, "customer")
|
|
}
|
|
}
|
|
}
|
|
|
|
if body.Target == "miler" || body.Target == "both" {
|
|
if booking.Assignedmileruserid != nil {
|
|
var profile models.MilerProfile
|
|
if err := db.DB.Where("userid = ?", *booking.Assignedmileruserid).First(&profile).Error; err == nil && profile.Devicetoken != "" {
|
|
if err := notify.SendToDevice(profile.Devicetoken, body.Title, body.Message, data); err != nil {
|
|
utils.Warn("InternalNotify: failed to notify miler", "booking_id", booking.Bookingid, "error", err)
|
|
} else {
|
|
sent = append(sent, "miler")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return utils.OK(c, fiber.Map{
|
|
"sent": true,
|
|
"targets": sent,
|
|
})
|
|
}
|
|
|
|
// InternalReassign releases the current miler assignment and re-triggers the
|
|
// auto-assignment engine for a stalled booking. Only valid when the booking is
|
|
// in Miler_Assigned or Pickup_Scheduled state.
|
|
// Auth: X-Internal-Key header (see InternalKeyAuth middleware).
|
|
//
|
|
// POST /api/v1/internal/bookings/:id/reassign
|
|
func InternalReassign(c *fiber.Ctx) error {
|
|
id, err := strconv.Atoi(c.Params("id"))
|
|
if err != nil {
|
|
return utils.BadRequest(c, "invalid booking ID")
|
|
}
|
|
|
|
tx := db.DB.Begin()
|
|
|
|
var booking models.PickupBooking
|
|
if err := tx.First(&booking, id).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.NotFound(c, "booking not found")
|
|
}
|
|
|
|
if booking.Status != constants.BookingMilerAssigned && booking.Status != constants.BookingPickupScheduled {
|
|
tx.Rollback()
|
|
return utils.BadRequest(c, "booking must be in Miler_Assigned or Pickup_Scheduled state to reassign")
|
|
}
|
|
|
|
now := time.Now()
|
|
|
|
if booking.Assignedmileruserid != nil {
|
|
if err := tx.Model(&models.MilerProfile{}).
|
|
Where("userid = ?", *booking.Assignedmileruserid).
|
|
Updates(map[string]interface{}{
|
|
"availabilitystatus": constants.MilerAvailable,
|
|
"updatedat": now,
|
|
}).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to free previous miler")
|
|
}
|
|
|
|
if err := tx.Delete(&models.BookingAssignment{},
|
|
"bookingid = ? AND assignmentstatus IN ?",
|
|
booking.Bookingid,
|
|
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted},
|
|
).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to clear previous assignment")
|
|
}
|
|
}
|
|
|
|
booking.Status = constants.BookingCreated
|
|
booking.Assignedmileruserid = nil
|
|
booking.Updatedat = now
|
|
if err := tx.Save(&booking).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to reset booking")
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return utils.Internal(c, "failed to reassign booking")
|
|
}
|
|
|
|
if booking.Bookingsource == "CRM_Console" {
|
|
go assignment.AssignCRMMiler(booking.Bookingid)
|
|
} else {
|
|
go assignment.AssignCustomerMiler(booking.Bookingid)
|
|
}
|
|
|
|
utils.Info("InternalReassign: reassignment triggered",
|
|
"booking_id", booking.Bookingid,
|
|
"booking_source", booking.Bookingsource,
|
|
)
|
|
|
|
return utils.OK(c, fiber.Map{
|
|
"reassignment": "triggered",
|
|
"booking_id": booking.Bookingid,
|
|
})
|
|
}
|