Milers now choose their own PIN the first time they log in, instead of the console assigning a shared default: - CreateMiler always creates a rider with an empty Password (PIN field removed from MilerCreateRequest); any client-supplied PIN is ignored, making "riders set their own PIN" a backend invariant, not a console convention. - LoginMiler returns `pin_set` so the app routes to enter-PIN vs set-PIN. - New POST /miler/set-pin (SetMilerPin): self-service first PIN, allowed ONLY when the account has none yet (409 otherwise, so it can't overwrite/take over an active account), then logs the rider in. Self-service and throttle-only is safe because of that guard; OTP-gate it once the SMS gateway is live. - verify-pin and set-pin share issueMilerSession so the two success responses can't drift. Also switches BookingPickupComplete's timestamp to DBNow() (IST) so the compatibility-flow inwardedat matches the reconciliation windows. Existing riders keep their PIN and are unaffected; blanking their password to move them onto self-set is a separate, deliberate DB step. go build, go vet and go test ./... all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
4061 lines
131 KiB
Go
4061 lines
131 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/cxstage"
|
|
"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))
|
|
// Ceiling comes from utils.MaxPageSize rather than a literal, so this
|
|
// endpoint and utils.ParsePage cannot disagree about what a caller may
|
|
// ask for. The hard-coded 100 here silently capped every client that
|
|
// asked for more: the console drains this list a page at a time and
|
|
// requests 1000, so it was issuing ten times the round trips for the
|
|
// same rows and hitting its own page budget at 1,200 — past which the
|
|
// counts it renders are floors, not totals.
|
|
//
|
|
// The DEFAULT stays 20. Callers that do not ask for a page size keep
|
|
// exactly the response they get today; only a caller that explicitly
|
|
// requests more sees any change.
|
|
pagesize := min(utils.MaxPageSize, 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)
|
|
}
|
|
// Zone/city scoping. 0 means "all zones".
|
|
if applocationid := c.QueryInt("applocationid", 0); applocationid != 0 {
|
|
query = query.Where("applocationid = ?", applocationid)
|
|
}
|
|
// 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),
|
|
"firstname": cust.Firstname,
|
|
"lastname": cust.Lastname,
|
|
"phone": cust.Phone,
|
|
"email": cust.Email,
|
|
"doorno": cust.Doorno,
|
|
"address": cust.Address,
|
|
"suburb": cust.Suburb,
|
|
"city": cust.City,
|
|
"state": cust.State,
|
|
"postcode": cust.Postcode,
|
|
"landmark": cust.Landmark,
|
|
"latitude": cust.Latitude,
|
|
"longitude": cust.Longitude,
|
|
"applocationid": cust.Applocationid,
|
|
"status": cust.Status,
|
|
"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")
|
|
}
|
|
|
|
// Pointers so "field omitted" is distinguishable from "field cleared to
|
|
// empty" — the console sends only what it changed, and a missing address
|
|
// line must not blank a stored one.
|
|
req := new(struct {
|
|
Name *string `json:"name"`
|
|
Firstname *string `json:"firstname"`
|
|
Lastname *string `json:"lastname"`
|
|
Phone *string `json:"phone"`
|
|
Email *string `json:"email"`
|
|
Doorno *string `json:"doorno"`
|
|
Address *string `json:"address"`
|
|
Suburb *string `json:"suburb"`
|
|
City *string `json:"city"`
|
|
State *string `json:"state"`
|
|
Postcode *string `json:"postcode"`
|
|
Landmark *string `json:"landmark"`
|
|
Latitude *float64 `json:"latitude"`
|
|
Longitude *float64 `json:"longitude"`
|
|
Applocationid *int `json:"applocationid"`
|
|
})
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
updates := map[string]interface{}{}
|
|
|
|
if req.Phone != nil {
|
|
phone := *req.Phone
|
|
if len(phone) != 10 || strings.IndexFunc(phone, func(r rune) bool { return r < '0' || r > '9' }) != -1 {
|
|
return utils.BadRequest(c, "phone must be 10 digits")
|
|
}
|
|
updates["phone"] = phone
|
|
}
|
|
|
|
// firstname/lastname sent explicitly win; a single name field is split as a
|
|
// fallback for callers that still send it.
|
|
if req.Firstname != nil {
|
|
updates["firstname"] = strings.TrimSpace(*req.Firstname)
|
|
}
|
|
if req.Lastname != nil {
|
|
updates["lastname"] = strings.TrimSpace(*req.Lastname)
|
|
}
|
|
if req.Firstname == nil && req.Lastname == nil && req.Name != nil {
|
|
name := strings.TrimSpace(*req.Name)
|
|
parts := strings.SplitN(name, " ", 2)
|
|
updates["firstname"] = parts[0]
|
|
if len(parts) > 1 {
|
|
updates["lastname"] = parts[1]
|
|
} else {
|
|
updates["lastname"] = ""
|
|
}
|
|
}
|
|
|
|
if req.Email != nil {
|
|
updates["email"] = *req.Email
|
|
}
|
|
if req.Doorno != nil {
|
|
updates["doorno"] = *req.Doorno
|
|
}
|
|
if req.Address != nil {
|
|
updates["address"] = *req.Address
|
|
}
|
|
if req.Suburb != nil {
|
|
updates["suburb"] = *req.Suburb
|
|
}
|
|
if req.City != nil {
|
|
updates["city"] = *req.City
|
|
}
|
|
if req.State != nil {
|
|
updates["state"] = *req.State
|
|
}
|
|
if req.Postcode != nil {
|
|
updates["postcode"] = *req.Postcode
|
|
}
|
|
if req.Landmark != nil {
|
|
updates["landmark"] = *req.Landmark
|
|
}
|
|
if req.Latitude != nil {
|
|
updates["latitude"] = *req.Latitude
|
|
}
|
|
if req.Longitude != nil {
|
|
updates["longitude"] = *req.Longitude
|
|
}
|
|
if req.Applocationid != nil {
|
|
updates["applocationid"] = *req.Applocationid
|
|
}
|
|
|
|
if len(updates) == 0 {
|
|
return utils.BadRequest(c, "no fields to update")
|
|
}
|
|
updates["updatedat"] = time.Now()
|
|
|
|
if err := db.DB.Model(&models.AppCustomer{}).
|
|
Where("appcustomerid = ?", customer.Appcustomerid).
|
|
Updates(updates).Error; err != nil {
|
|
return utils.Internal(c, "failed to update customer")
|
|
}
|
|
|
|
// Return the persisted row so the console reflects exactly what was stored.
|
|
if err := db.DB.First(&customer, customer.Appcustomerid).Error; err != nil {
|
|
return utils.Internal(c, "failed to reload customer")
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"success": true,
|
|
"data": fiber.Map{
|
|
"appcustomerid": customer.Appcustomerid,
|
|
"name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
|
|
"firstname": customer.Firstname,
|
|
"lastname": customer.Lastname,
|
|
"phone": customer.Phone,
|
|
"email": customer.Email,
|
|
"doorno": customer.Doorno,
|
|
"address": customer.Address,
|
|
"suburb": customer.Suburb,
|
|
"city": customer.City,
|
|
"state": customer.State,
|
|
"postcode": customer.Postcode,
|
|
"landmark": customer.Landmark,
|
|
"latitude": customer.Latitude,
|
|
"longitude": customer.Longitude,
|
|
"applocationid": customer.Applocationid,
|
|
"status": customer.Status,
|
|
},
|
|
})
|
|
}
|
|
|
|
// GetAdminCustomersSummary returns the stat-tile counts the console header
|
|
// shows, so the client does not have to page through the whole list to derive
|
|
// them. Scoped the same way as the list: a client sees only their own
|
|
// customers, Doormile staff can narrow with ?tenantid= / ?applocationid=.
|
|
func GetAdminCustomersSummary(c *fiber.Ctx) error {
|
|
base := db.DB.Model(&models.AppCustomer{})
|
|
|
|
tenantID, allowed := effectiveTenantID(c)
|
|
if !allowed {
|
|
return utils.Forbidden(c, "you can only view your own tenant")
|
|
}
|
|
if tenantID != 0 {
|
|
base = base.Where("appcustomerid IN (?)",
|
|
db.DB.Model(&models.PickupBooking{}).Select("appcustomerid").
|
|
Where("tenantid = ?", tenantID))
|
|
}
|
|
if applocationid := c.QueryInt("applocationid", 0); applocationid != 0 {
|
|
base = base.Where("applocationid = ?", applocationid)
|
|
}
|
|
|
|
count := func(status string) int64 {
|
|
var n int64
|
|
q := base.Session(&gorm.Session{})
|
|
if status != "" {
|
|
q = q.Where("status = ?", status)
|
|
}
|
|
q.Count(&n)
|
|
return n
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"success": true,
|
|
"data": fiber.Map{
|
|
"total": count(""),
|
|
"active": count(constants.CustomerStatusActive),
|
|
"blocked": count(constants.CustomerStatusBlocked),
|
|
},
|
|
})
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
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,
|
|
// Empty PIN by design: the rider self-sets it on first login via
|
|
// /miler/set-pin. See MilerCreateRequest — no console-set PIN.
|
|
Password: "",
|
|
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
|
|
// --------------------
|
|
|
|
// bookingDestinationCounts is one row of the grouped bookingdestinations
|
|
// aggregate: how many destinations a booking carries and how many packages they
|
|
// add up to across all of them.
|
|
type bookingDestinationCounts struct {
|
|
Bookingid int `gorm:"column:bookingid"`
|
|
Destinationcount int `gorm:"column:destinationcount"`
|
|
Totalpackagecount int `gorm:"column:totalpackagecount"`
|
|
}
|
|
|
|
// applyDestinationCounts writes the grouped counts onto the bookings they
|
|
// belong to. A booking with no bookingdestinations rows — every
|
|
// console-created booking, and every booking predating the customer app —
|
|
// keeps the 0/0 zero value, which is the correct answer and the one the
|
|
// console distinguishes from a single-destination booking.
|
|
//
|
|
// Split out from the handler because it is the whole of the mapping logic and
|
|
// the only part that can be tested without a database.
|
|
func applyDestinationCounts(bookings []models.PickupBooking, counts []bookingDestinationCounts) {
|
|
if len(bookings) == 0 || len(counts) == 0 {
|
|
return
|
|
}
|
|
byBooking := make(map[int]bookingDestinationCounts, len(counts))
|
|
for _, row := range counts {
|
|
byBooking[row.Bookingid] = row
|
|
}
|
|
for i := range bookings {
|
|
row, ok := byBooking[bookings[i].Bookingid]
|
|
if !ok {
|
|
continue
|
|
}
|
|
bookings[i].Destinationcount = row.Destinationcount
|
|
bookings[i].Totalpackagecount = row.Totalpackagecount
|
|
}
|
|
}
|
|
|
|
func GetAdminBookings(c *fiber.Ctx) error {
|
|
pageno := max(1, c.QueryInt("pageno", 1))
|
|
// Ceiling comes from utils.MaxPageSize rather than a literal, so this
|
|
// endpoint and utils.ParsePage cannot disagree about what a caller may
|
|
// ask for. The hard-coded 100 here silently capped every client that
|
|
// asked for more: the console drains this list a page at a time and
|
|
// requests 1000, so it was issuing ten times the round trips for the
|
|
// same rows and hitting its own page budget at 1,200 — past which the
|
|
// counts it renders are floors, not totals.
|
|
//
|
|
// The DEFAULT stays 20. Callers that do not ask for a page size keep
|
|
// exactly the response they get today; only a caller that explicitly
|
|
// requests more sees any change.
|
|
pagesize := min(utils.MaxPageSize, 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")
|
|
}
|
|
|
|
// Newest first, and deterministically so.
|
|
//
|
|
// Without an ORDER BY the row order is unspecified — Postgres returns heap
|
|
// order, which in practice is oldest first. Two things follow, and both bit:
|
|
//
|
|
// 1. The newest booking sits on the LAST page. The console drains a
|
|
// bounded window, so once pickupbookings outgrows that window a
|
|
// just-created customer-app booking can never reach the Orders page at
|
|
// all. It is written correctly and is simply never fetched.
|
|
// 2. OFFSET pagination over an unordered result is not stable: the same
|
|
// page can return different rows across two requests, so draining pages
|
|
// can duplicate and skip rows well before that threshold.
|
|
//
|
|
// bookingid rather than createdat: it is the primary key and unique, so the
|
|
// sort needs no tiebreaker and the paging cannot wobble between equal
|
|
// timestamps. It also matches the order the console already sorts into
|
|
// client-side, so page 1 is the newest page by both definitions.
|
|
// Destinations ride the list, not just the detail read.
|
|
//
|
|
// A customer-app booking is one pickup carrying N drops, and the console's
|
|
// Bookings page is the screen that shows them. Without this the list could
|
|
// only report `destinationcount` and the row's mirrored destination 0, so a
|
|
// three-drop pickup looked identical to a one-drop pickup until somebody
|
|
// opened the drawer — which is the whole reason that page was reaching for
|
|
// the customer app's own endpoint instead.
|
|
//
|
|
// One extra query for the page (GORM batches a Preload with an IN clause),
|
|
// not one per row, and ordered by seq because seq is the customer-facing
|
|
// position: it is the {index} in
|
|
// PATCH /customer/bookings/{ref}/destinations/{index}, so the order the
|
|
// console renders has to be the order the customer addresses. Same preload
|
|
// GetAdminBookingDetails already uses, so the list and the drawer cannot
|
|
// disagree about a booking's drops.
|
|
var bookings []models.PickupBooking
|
|
if err := query.Preload("Parcels").Preload("ServiceOptions").
|
|
Preload("Destinations", func(d *gorm.DB) *gorm.DB {
|
|
return d.Order("seq ASC")
|
|
}).
|
|
Order("bookingid DESC").
|
|
Offset(offset).Limit(pagesize).Find(&bookings).Error; err != nil {
|
|
return utils.Internal(c, "failed to fetch bookings")
|
|
}
|
|
|
|
// Surface the live consignment status next to the booking. Once a booking is
|
|
// picked up its own status stops moving (it sits at Converted_To_Consignment),
|
|
// while the parcel keeps advancing on the consignment — Out_for_Delivery,
|
|
// Delivered. Without this the console can only show the frozen booking status
|
|
// and a collected order reads as a generic "Active". Batched: one IN query for
|
|
// the whole page, not one per row.
|
|
consignmentIDs := make([]int, 0, len(bookings))
|
|
for _, b := range bookings {
|
|
if b.Consignmentid != nil {
|
|
consignmentIDs = append(consignmentIDs, *b.Consignmentid)
|
|
}
|
|
}
|
|
if len(consignmentIDs) > 0 {
|
|
var consignments []models.Consignment
|
|
db.DB.Select("consignmentid, status").Where("consignmentid IN ?", consignmentIDs).Find(&consignments)
|
|
statusByConsignment := make(map[int]string, len(consignments))
|
|
for _, cn := range consignments {
|
|
statusByConsignment[cn.Consignmentid] = cn.Status
|
|
}
|
|
for i := range bookings {
|
|
if bookings[i].Consignmentid != nil {
|
|
bookings[i].Consignmentstatus = statusByConsignment[*bookings[i].Consignmentid]
|
|
}
|
|
}
|
|
}
|
|
|
|
// How many destinations each booking carries, and the packages summed across
|
|
// them. A customer-app pickup is ONE booking with N destinations, and the
|
|
// console needs to render "3 destinations · 4 packages" on the collapsed row.
|
|
// The full array is deliberately NOT preloaded here: the console drains up to
|
|
// 12 pages of 100 bookings and only opens one row at a time, so the array is
|
|
// payload the list never reads. Batched exactly like the consignment status
|
|
// above — one grouped query for the whole page, never one per row.
|
|
bookingIDs := make([]int, 0, len(bookings))
|
|
for _, b := range bookings {
|
|
bookingIDs = append(bookingIDs, b.Bookingid)
|
|
}
|
|
if len(bookingIDs) > 0 {
|
|
var counts []bookingDestinationCounts
|
|
db.DB.Model(&models.BookingDestination{}).
|
|
Select("bookingid, COUNT(*) AS destinationcount, COALESCE(SUM(packagecount), 0) AS totalpackagecount").
|
|
Where("bookingid IN ?", bookingIDs).
|
|
Group("bookingid").
|
|
Scan(&counts)
|
|
applyDestinationCounts(bookings, counts)
|
|
}
|
|
|
|
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"`
|
|
// PickupSourceType says what kind of place this booking is collected from —
|
|
// one of constants.PickupSource*. Optional: left blank it is classified from
|
|
// what the payload carries (a base id, a client site id, or neither), so
|
|
// existing console callers keep working unchanged. Send it explicitly to
|
|
// create a Base/Hub-origin booking.
|
|
PickupSourceType string `json:"pickup_source_type"`
|
|
// Pickuphubid names the base a Base → Customer booking is collected FROM.
|
|
// Required when pickup_source_type is "hub"; supplying it is also enough on
|
|
// its own, since a booking that names a base is a base-origin booking. The
|
|
// base's own address, pincode and coordinates fill in whatever the caller
|
|
// left blank, so the dispatch board never has to retype a gate address.
|
|
Pickuphubid *int `json:"pickuphubid"`
|
|
// Sourceid is accepted as an alias for whichever id the source type implies —
|
|
// the app and the console have both used this spelling. With
|
|
// pickup_source_type "hub" it is a base id; otherwise a client-site id.
|
|
Sourceid *int `json:"sourceid"`
|
|
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.
|
|
// createExpressBooking creates one console booking. autoAssign controls whether
|
|
// it kicks off the per-booking assignment engine (AssignCRMMiler) inline. Single
|
|
// bookings assign inline; a bulk batch passes false, because the whole batch is
|
|
// handed to the ExpressDispatchAgent as one unit instead — assigning inline as
|
|
// well would assign every booking twice by two different logics.
|
|
func createExpressBooking(req AdminBookingRequest, autoAssign bool) (*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.
|
|
// A base-origin pickup (Base/Hub → Customer). The base is the actual place the
|
|
// rider collects from, so its address and coordinates become the booking's
|
|
// pickup point and the row records both the type and the base id — that pair
|
|
// is what the rider app reads to title the stop as a Base rather than as the
|
|
// rider's own office, and what the dispatch board reads back on the row.
|
|
baseID := req.Pickuphubid
|
|
if baseID == nil && strings.EqualFold(req.PickupSourceType, constants.PickupSourceHub) {
|
|
baseID = req.Sourceid
|
|
}
|
|
if baseID != nil {
|
|
var hub models.Hub
|
|
if err := db.DB.Where("hubid = ? AND deletedat IS NULL", *baseID).First(&hub).Error; err != nil {
|
|
return nil, &expressBookingValidationError{"pickuphubid does not match a known base"}
|
|
}
|
|
req.PickupSourceType = constants.PickupSourceHub
|
|
req.Pickuphubid = &hub.Hubid
|
|
if req.Pickupaddress == "" {
|
|
req.Pickupaddress = hub.Address
|
|
}
|
|
if req.Pickuppincode == "" {
|
|
req.Pickuppincode = hub.Pincode
|
|
}
|
|
if req.Pickuplatitude == 0 && req.Pickuplongitude == 0 {
|
|
req.Pickuplatitude, req.Pickuplongitude = hub.Latitude, hub.Longitude
|
|
}
|
|
}
|
|
|
|
siteID := req.Tenantlocationid
|
|
// sourceid doubles as the client-site id when the source is not a base.
|
|
if siteID == nil && baseID == nil {
|
|
siteID = req.Sourceid
|
|
}
|
|
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.Pickuphubid == nil {
|
|
req.Tenantlocationid = matchTenantLocation(req.Tenantid, req.Pickupaddress, req.Pickuplatitude, req.Pickuplongitude)
|
|
}
|
|
|
|
// Classify the source once, here, rather than leaving every reader to guess.
|
|
// A caller-supplied type wins as long as it is one we know; an unknown word is
|
|
// dropped rather than stored, so the column never holds something the app has
|
|
// no meaning for. A door pickup is recorded as "customer" explicitly — the
|
|
// whole point of the column is that a blank cannot be told apart from an
|
|
// address nobody filled in.
|
|
switch {
|
|
case req.Pickuphubid != nil:
|
|
req.PickupSourceType = constants.PickupSourceHub
|
|
case strings.EqualFold(req.PickupSourceType, constants.PickupSourceStore):
|
|
req.PickupSourceType = constants.PickupSourceStore
|
|
case strings.EqualFold(req.PickupSourceType, constants.PickupSourceCustomer):
|
|
req.PickupSourceType = constants.PickupSourceCustomer
|
|
case strings.EqualFold(req.PickupSourceType, constants.PickupSourceMerchant):
|
|
// Honoured even with no site id attached. A merchant collection with no
|
|
// configured location is still a shop, and telling the rider "customer
|
|
// door" would send them looking for a person who is not there.
|
|
req.PickupSourceType = constants.PickupSourceMerchant
|
|
case req.Tenantlocationid != nil:
|
|
req.PickupSourceType = constants.PickupSourceMerchant
|
|
default:
|
|
req.PickupSourceType = constants.PickupSourceCustomer
|
|
}
|
|
|
|
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,
|
|
Pickupsourcetype: req.PickupSourceType,
|
|
Pickuphubid: req.Pickuphubid,
|
|
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")
|
|
}
|
|
|
|
if autoAssign {
|
|
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, true)
|
|
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)
|
|
|
|
// When the express agent is enabled, a bulk create only *accumulates* pending
|
|
// bookings — inline per-booking assignment is suppressed so orders can pile up
|
|
// batch by batch, and the operator later fires the agent over the whole lot
|
|
// with POST /admin/expressbooking/dispatch. When disabled (default), nothing
|
|
// changes from the old behavior: each booking auto-assigns inline.
|
|
agentEnabled := expressAgentEnabled()
|
|
|
|
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, !agentEnabled)
|
|
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
|
|
// Destinations is the customer-app half of the booking: one pickup carries N
|
|
// of them, each with its own consignment, tracking number and stage once the
|
|
// miler completes pickup (cxPickupFanout.go). The relation has been declared
|
|
// on the model since the customer app shipped and nothing preloaded it, which
|
|
// is the entire reason the console could only ever show one drop.
|
|
//
|
|
// Ordered by seq ascending and never by anything else: seq is the
|
|
// customer-facing position and the {index} in
|
|
// PATCH /customer/bookings/{ref}/destinations/{index}, so the order the
|
|
// console renders has to be the order the customer addresses.
|
|
q := scopeToOwnTenant(c, db.DB.
|
|
Preload("Parcels").
|
|
Preload("ServiceOptions").
|
|
Preload("Payments").
|
|
Preload("Destinations", func(d *gorm.DB) *gorm.DB {
|
|
return d.Order("seq ASC")
|
|
}), "tenantid")
|
|
if err := q.First(&booking, id).Error; err != nil {
|
|
return utils.NotFound(c, "booking not found")
|
|
}
|
|
|
|
// The routing decision and the inputs it was made from, so a support call
|
|
// about "why does this say handover instead of delivery" is a lookup rather
|
|
// than a reconstruction. Everything here is derived from stored state — no
|
|
// new columns, and it stays right if the routing rule changes, because it
|
|
// reads the same helpers the pivot does.
|
|
var customer models.AppCustomer
|
|
db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer)
|
|
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&booking,
|
|
customer.Firstname+" "+customer.Lastname)
|
|
|
|
routing := fiber.Map{
|
|
"pickup_source_type": sourceType,
|
|
"pickup_source_id": sourceID,
|
|
"pickup_source_name": sourceName,
|
|
"from_address": sourceAddress,
|
|
"from_pincode": booking.Pickuppincode,
|
|
"to_address": booking.Deliveryaddress,
|
|
"destination_pincode": booking.Deliverypincode,
|
|
// hyperlocal: same postal area, so no base leg — the collecting rider
|
|
// carries it to the receiver. Otherwise it goes through a base. This is the
|
|
// decision pickup-complete makes, shown with the inputs it makes it from.
|
|
"is_hyperlocal": isHyperlocalBooking(booking.Pickuppincode, booking.Deliverypincode,
|
|
booking.Pickuplatitude, booking.Pickuplongitude,
|
|
booking.Deliverylatitude, booking.Deliverylongitude),
|
|
}
|
|
|
|
// Before pickup the decision has not been taken yet, so the routing result is
|
|
// a projection; after pickup it is fact, read off the consignment.
|
|
if booking.Consignmentid != nil {
|
|
var cn models.Consignment
|
|
if db.DB.First(&cn, *booking.Consignmentid).Error == nil {
|
|
booking.Consignmentstatus = cn.Status
|
|
routing["consignment_state"] = cn.Status
|
|
routing["next_action"] = nextActionForConsignment(cn.Status)
|
|
routing["next_hub"] = renderBase(loadHub(cn.Currenthubid))
|
|
routing["inwardedat"] = cn.Inwardedat
|
|
routing["decided"] = true
|
|
}
|
|
} else {
|
|
routing["consignment_state"] = ""
|
|
routing["next_action"] = constants.NextActionPickup
|
|
routing["next_hub"] = nil
|
|
routing["decided"] = false
|
|
}
|
|
|
|
// routing rides alongside the booking's own fields rather than nesting them
|
|
// under a new key — the console reads this response as a booking object today,
|
|
// and moving those fields would break every screen that does.
|
|
raw, err := json.Marshal(booking)
|
|
if err != nil {
|
|
return utils.OK(c, booking)
|
|
}
|
|
payload := map[string]interface{}{}
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
return utils.OK(c, booking)
|
|
}
|
|
payload["routing"] = routing
|
|
return utils.OK(c, payload)
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// Tell the customer's projection too. Without this the pickup keeps
|
|
// rendering as active and cancellable in the customer app, because
|
|
// customerstatus was written as "active" at booking time and nothing here
|
|
// ever moved it. Best-effort and outside the save above: an ops cancel that
|
|
// has already committed must not be reported as failed because the
|
|
// customer-side write did not land. No-op for console-created bookings.
|
|
if err := cxstage.Cancel(db.DB, booking.Bookingid, "Cancelled by Doormile operations",
|
|
constants.CxActorOps, opsActorID(c), "POST /admin/bookings/{id}/cancel"); err != nil {
|
|
utils.Error("AdminCancelBooking: could not update the customer projection",
|
|
"booking_id", booking.Bookingid, "error", err)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Same reason as AdminCancelBooking: without this the pickup keeps
|
|
// rendering as active and cancellable in the customer app. No-op for
|
|
// console-created bookings.
|
|
if err := cxstage.Cancel(db.DB, booking.Bookingid, "Cancelled by Doormile operations",
|
|
constants.CxActorOps, opsActorID(c), "POST /admin/bookings/bulk-cancel"); err != nil {
|
|
utils.Error("AdminBulkCancelBookings: could not update the customer projection",
|
|
"booking_id", booking.Bookingid, "error", err)
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
// opsActorID returns the console user behind an ops action, for the customer's
|
|
// audit trail. Nil when the request carries no user id, which is a legitimate
|
|
// state for an internal caller rather than something to fail on.
|
|
func opsActorID(c *fiber.Ctx) *int {
|
|
if uid, ok := c.Locals("userid").(int); ok && uid != 0 {
|
|
return &uid
|
|
}
|
|
return nil
|
|
}
|