feat: 14 new endpoints closing jupiter->Doormile API gaps, plus two tenant-scoping bug fixes
New endpoints: - Admin: partner CRUD (GET/POST /admin/partners, GET/PUT/DELETE /admin/partners/:id), bulk express booking create (POST /admin/expressbooking/bulk), bulk cancel (POST /admin/bookings/bulk-cancel), reports (GET /admin/reports), password change (PUT /admin/profile/password), miler notify (POST /admin/milers/:id/notify) - Miler: PIN reset (POST /miler/reset-pin), cancel assignment (POST /miler/bookings/:bookingid/cancel), skip delivery (POST /miler/consignments/:id/skip) - Hub: batch assign (POST /hub/bookings/batch-assign) - greedy nearest-rider queue clearing, capped per rider Bug fixes: - BookingPickupComplete now sets Consignment.Tenantid from the booking's tenant instead of the completing miler's own tenant (fixes cross-tenant shipment mis-attribution) - GetHubUnassignedBookings/GetHubBookingsRange now scoped via scopeBookingsToOwnTenant (fixes partner hub staff seeing other tenants' bookings) Also: CRM booking routes renamed to expressbooking to end the naming collision with the separate CRM clients feature; PickupBooking gains nullable Tenantid; adds CLAUDE.md project memory. Verified: go build ./... and go vet ./... both clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -112,6 +112,157 @@ func GetAdminDashboard(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// 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())
|
||||
}
|
||||
|
||||
tenantID := c.Query("tenantid")
|
||||
hubID := c.Query("hubid")
|
||||
|
||||
var totalBookings int64
|
||||
db.DB.Model(&models.PickupBooking{}).Where("createdat BETWEEN ? AND ?", from, to).Count(&totalBookings)
|
||||
|
||||
var delivered int64
|
||||
db.DB.Model(&models.PickupBooking{}).
|
||||
Where("status = ? AND updatedat BETWEEN ? AND ?", constants.BookingConvertedConsignment, from, to).
|
||||
Count(&delivered)
|
||||
|
||||
var cancelled int64
|
||||
db.DB.Model(&models.PickupBooking{}).
|
||||
Where("status = ? AND updatedat BETWEEN ? AND ?", constants.BookingCancelled, from, to).
|
||||
Count(&cancelled)
|
||||
|
||||
consignmentQuery := db.DB.Model(&models.Consignment{}).Where("createdat BETWEEN ? AND ?", from, to)
|
||||
if tenantID != "" {
|
||||
consignmentQuery = consignmentQuery.Where("tenantid = ?", tenantID)
|
||||
}
|
||||
if hubID != "" {
|
||||
consignmentQuery = consignmentQuery.Where("currenthubid = ?", hubID)
|
||||
}
|
||||
var totalConsignments int64
|
||||
consignmentQuery.Count(&totalConsignments)
|
||||
|
||||
var codCollected float64
|
||||
db.DB.Model(&models.BookingPayment{}).
|
||||
Where("paymentstatus = ? AND createdat BETWEEN ? AND ?", constants.PaymentStatusPaid, from, to).
|
||||
Select("COALESCE(SUM(amount), 0)").Scan(&codCollected)
|
||||
|
||||
var openExceptions int64
|
||||
db.DB.Model(&models.ConsignmentException{}).
|
||||
Where("status != ? AND createdat BETWEEN ? AND ?", constants.ExceptionClosed, from, to).
|
||||
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
|
||||
db.DB.Raw(`
|
||||
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 ?
|
||||
WHERE h.deletedat IS NULL
|
||||
GROUP BY h.hubid, h.hubname
|
||||
ORDER BY delivered DESC
|
||||
`, constants.ConsignmentDelivered, from, to).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
|
||||
db.DB.Raw(`
|
||||
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 ?
|
||||
GROUP BY t.tenantid, t.tenantname
|
||||
ORDER BY bookings DESC
|
||||
`, from, to).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 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"`
|
||||
}
|
||||
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}
|
||||
if hubID != "" {
|
||||
riderQuery += " WHERE mp.hubid = ? "
|
||||
args = append(args, hubID)
|
||||
}
|
||||
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"),
|
||||
"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_rider": byRider,
|
||||
})
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// APP USERS MANAGEMENT
|
||||
// --------------------
|
||||
@@ -665,6 +816,120 @@ func DeleteTenantCustomer(c *fiber.Ctx) error {
|
||||
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
|
||||
// --------------------
|
||||
@@ -957,6 +1222,44 @@ func GetMilerDetails(c *fiber.Ctx) error {
|
||||
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")
|
||||
}
|
||||
|
||||
var profile models.MilerProfile
|
||||
if err := db.DB.Where("milerprofileid = ?", id).First(&profile).Error; err != nil {
|
||||
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"))
|
||||
var profile models.MilerProfile
|
||||
@@ -1053,13 +1356,18 @@ func GetAdminBookings(c *fiber.Ctx) error {
|
||||
pagesize := min(100, max(1, c.QueryInt("pagesize", 20)))
|
||||
offset := (pageno - 1) * pagesize
|
||||
|
||||
query := db.DB.Model(&models.PickupBooking{})
|
||||
if tenantID := c.Query("tenantid"); tenantID != "" {
|
||||
query = query.Where("tenantid = ?", tenantID)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := db.DB.Model(&models.PickupBooking{}).Count(&total).Error; err != nil {
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return utils.Internal(c, "failed to count bookings")
|
||||
}
|
||||
|
||||
var bookings []models.PickupBooking
|
||||
if err := db.DB.Preload("Parcels").Preload("ServiceOptions").
|
||||
if err := query.Preload("Parcels").Preload("ServiceOptions").
|
||||
Offset(offset).Limit(pagesize).Find(&bookings).Error; err != nil {
|
||||
return utils.Internal(c, "failed to fetch bookings")
|
||||
}
|
||||
@@ -1076,42 +1384,60 @@ func GetAdminBookings(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
func CreateCRMBooking(c *fiber.Ctx) error {
|
||||
type AdminBookingRequest struct {
|
||||
Appcustomerid int `json:"appcustomerid"`
|
||||
CustomerPhone string `json:"customer_phone"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
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"`
|
||||
}
|
||||
// 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
req := new(AdminBookingRequest)
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
// expressBookingValidationError marks a createExpressBooking failure as a bad
|
||||
// request (missing/invalid input) rather than a server-side failure, so
|
||||
// CreateExpressBooking can still return the right HTTP status after the
|
||||
// validation logic moved into the shared helper below.
|
||||
type expressBookingValidationError struct{ msg string }
|
||||
|
||||
func (e *expressBookingValidationError) Error() string { return e.msg }
|
||||
|
||||
// createExpressBooking holds the actual booking-creation logic, shared by
|
||||
// CreateExpressBooking (one booking, used by the "New Booking" form) and
|
||||
// AdminBulkCreateBookings (many, used by CSV/bulk import). Takes no
|
||||
// *fiber.Ctx — the original function never touched c after BodyParser, so
|
||||
// both callers can use this identically.
|
||||
func createExpressBooking(req AdminBookingRequest) (*models.PickupBooking, error) {
|
||||
if req.Pickupaddress == "" || req.Pickuppincode == "" {
|
||||
return utils.BadRequest(c, "pickup address and pincode are required")
|
||||
return nil, &expressBookingValidationError{"pickup address and pincode are required"}
|
||||
}
|
||||
|
||||
if len(req.Parcels) == 0 {
|
||||
return utils.BadRequest(c, "at least one parcel is required")
|
||||
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"}
|
||||
}
|
||||
|
||||
tx := db.DB.Begin()
|
||||
@@ -1135,14 +1461,16 @@ func CreateCRMBooking(c *fiber.Ctx) error {
|
||||
}
|
||||
if err := tx.Create(&newCustomer).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to create customer record")
|
||||
return nil, fmt.Errorf("failed to create customer record")
|
||||
}
|
||||
customerID = newCustomer.Appcustomerid
|
||||
}
|
||||
}
|
||||
|
||||
tenantID := req.Tenantid
|
||||
booking := models.PickupBooking{
|
||||
Bookingno: generateBookingNo(),
|
||||
Tenantid: &tenantID,
|
||||
Appcustomerid: customerID,
|
||||
Pickupaddress: req.Pickupaddress,
|
||||
Pickuppincode: req.Pickuppincode,
|
||||
@@ -1164,7 +1492,7 @@ func CreateCRMBooking(c *fiber.Ctx) error {
|
||||
|
||||
if err := tx.Create(&booking).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to create booking")
|
||||
return nil, fmt.Errorf("failed to create booking")
|
||||
}
|
||||
|
||||
var totalWeight float64
|
||||
@@ -1197,7 +1525,7 @@ func CreateCRMBooking(c *fiber.Ctx) error {
|
||||
parcel.Insuranceamount = p.Declaredvalue * 0.01
|
||||
}
|
||||
|
||||
// If explicit insurance amount is provided from CRM, apply it to the first parcel
|
||||
// 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
|
||||
@@ -1205,7 +1533,7 @@ func CreateCRMBooking(c *fiber.Ctx) error {
|
||||
|
||||
if err := tx.Create(&parcel).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to save parcel details")
|
||||
return nil, fmt.Errorf("failed to save parcel details")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1265,7 +1593,7 @@ func CreateCRMBooking(c *fiber.Ctx) error {
|
||||
|
||||
if err := tx.Create(&srvOption).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to save service option")
|
||||
return nil, fmt.Errorf("failed to save service option")
|
||||
}
|
||||
|
||||
if requiresLargeVehicle || totalWeight > 20.0 {
|
||||
@@ -1277,12 +1605,12 @@ func CreateCRMBooking(c *fiber.Ctx) error {
|
||||
}
|
||||
if err := tx.Create(&reqVeh).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to save vehicle requirement")
|
||||
return nil, fmt.Errorf("failed to save vehicle requirement")
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return utils.Internal(c, "failed to create booking")
|
||||
return nil, fmt.Errorf("failed to create booking")
|
||||
}
|
||||
|
||||
go assignment.AssignCRMMiler(booking.Bookingid)
|
||||
@@ -1304,9 +1632,65 @@ func CreateCRMBooking(c *fiber.Ctx) error {
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
booking, err := createExpressBooking(*req)
|
||||
if err != nil {
|
||||
if _, ok := err.(*expressBookingValidationError); ok {
|
||||
return utils.BadRequest(c, err.Error())
|
||||
}
|
||||
return utils.Internal(c, err.Error())
|
||||
}
|
||||
|
||||
return utils.Created(c, booking)
|
||||
}
|
||||
|
||||
// AdminBulkCreateBookings creates several express-console bookings in one call — the
|
||||
// console's CSV/bulk-import flow. Each item is processed independently, same
|
||||
// per-item-result shape as AdminBulkCancelBookings, so one bad row (missing
|
||||
// address, unknown tenantid) doesn't block the rest of the batch.
|
||||
func AdminBulkCreateBookings(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Bookings []AdminBookingRequest `json:"bookings"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
if len(req.Bookings) == 0 {
|
||||
return utils.BadRequest(c, "bookings is required and must not be empty")
|
||||
}
|
||||
if len(req.Bookings) > 200 {
|
||||
return utils.BadRequest(c, "maximum 200 bookings per bulk request")
|
||||
}
|
||||
|
||||
type result struct {
|
||||
Index int `json:"index"`
|
||||
Success bool `json:"success"`
|
||||
Bookingid int `json:"bookingid,omitempty"`
|
||||
Bookingno string `json:"bookingno,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
results := make([]result, 0, len(req.Bookings))
|
||||
|
||||
for i, item := range req.Bookings {
|
||||
booking, err := createExpressBooking(item)
|
||||
if err != nil {
|
||||
results = append(results, result{Index: i, Success: false, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
results = append(results, result{Index: i, Success: true, Bookingid: booking.Bookingid, Bookingno: booking.Bookingno})
|
||||
}
|
||||
|
||||
return utils.OK(c, fiber.Map{"results": results})
|
||||
}
|
||||
|
||||
func GetAdminBookingDetails(c *fiber.Ctx) error {
|
||||
id, _ := strconv.Atoi(c.Params("id"))
|
||||
var booking models.PickupBooking
|
||||
@@ -1443,6 +1827,76 @@ func AdminCancelBooking(c *fiber.Ctx) error {
|
||||
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))
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if booking.Status == constants.BookingConvertedConsignment || booking.Status == constants.BookingCancelled {
|
||||
results = append(results, result{Bookingid: id, Success: false, Error: "cannot cancel a delivered or already cancelled booking"})
|
||||
continue
|
||||
}
|
||||
|
||||
booking.Status = constants.BookingCancelled
|
||||
booking.Updatedat = time.Now()
|
||||
if err := db.DB.Save(&booking).Error; err != nil {
|
||||
results = append(results, result{Bookingid: id, Success: false, Error: "failed to save"})
|
||||
continue
|
||||
}
|
||||
|
||||
if booking.Assignedmileruserid != nil {
|
||||
db.DB.Model(&models.MilerProfile{}).
|
||||
Where("userid = ?", *booking.Assignedmileruserid).
|
||||
Update("availabilitystatus", constants.MilerAvailable)
|
||||
}
|
||||
|
||||
if db.Js != nil {
|
||||
payload := map[string]interface{}{"bookingid": booking.Bookingid, "reason": "admin_bulk_cancelled"}
|
||||
if data, err := json.Marshal(payload); err == nil {
|
||||
if _, err := db.Js.Publish("booking.cancelled", data); err != nil {
|
||||
utils.Warn("AdminBulkCancelBookings: NATS publish failed", "booking_id", booking.Bookingid, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var customer models.AppCustomer
|
||||
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
|
||||
if err := notify.SendToDevice(customer.Devicetoken, "Booking Cancelled", "Your booking has been cancelled by operations", nil); err != nil {
|
||||
utils.Warn("AdminBulkCancelBookings: failed to notify customer", "booking_id", booking.Bookingid, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, result{Bookingid: id, Success: true})
|
||||
}
|
||||
|
||||
return utils.OK(c, fiber.Map{"results": results})
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// CONSIGNMENTS
|
||||
// --------------------
|
||||
@@ -2227,6 +2681,60 @@ func GetAdminProfile(c *fiber.Ctx) error {
|
||||
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).
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// hubAutoAssignTimeout bounds how long HubAutoAssign waits synchronously for
|
||||
@@ -211,6 +212,23 @@ func isDoormileStaff(staff *models.HubStaffAccount) bool {
|
||||
return staff.Tenantid == nil
|
||||
}
|
||||
|
||||
// scopeBookingsToOwnTenant restricts a bookings query to the requesting hub
|
||||
// staff's own tenant when they're partner staff (HubStaffAccount.Tenantid set)
|
||||
// — without this, a partner tenant's own hub staff could see every other
|
||||
// tenant's bookings passing through the same hub, which is exactly the kind
|
||||
// of cross-client leak a multi-tenant flow can't have. Doormile staff
|
||||
// (Tenantid nil) are unrestricted, same as everywhere else in this file.
|
||||
// Bookings created before Tenantid existed (nil) are only visible to
|
||||
// Doormile staff, never to partner staff, since they can't be proven to
|
||||
// belong to that partner.
|
||||
func scopeBookingsToOwnTenant(c *fiber.Ctx, query *gorm.DB) *gorm.DB {
|
||||
staff, err := getCurrentHubStaff(c)
|
||||
if err != nil || isDoormileStaff(staff) {
|
||||
return query
|
||||
}
|
||||
return query.Where("tenantid = ?", *staff.Tenantid)
|
||||
}
|
||||
|
||||
func GetHubDashboardStats(c *fiber.Ctx) error {
|
||||
hubID := c.Locals("hubid").(int)
|
||||
prefix := hubPincodePrefix(hubID)
|
||||
@@ -279,6 +297,7 @@ func GetHubUnassignedBookings(c *fiber.Ctx) error {
|
||||
if prefix != "" {
|
||||
query = query.Where("pickuppincode LIKE ?", prefix+"%")
|
||||
}
|
||||
query = scopeBookingsToOwnTenant(c, query)
|
||||
|
||||
var bookings []models.PickupBooking
|
||||
if err := query.Order("createdat DESC").Find(&bookings).Error; err != nil {
|
||||
@@ -343,6 +362,7 @@ func GetHubBookingsRange(c *fiber.Ctx) error {
|
||||
if prefix != "" {
|
||||
query = query.Where("pickuppincode LIKE ?", prefix+"%")
|
||||
}
|
||||
query = scopeBookingsToOwnTenant(c, query)
|
||||
|
||||
var bookings []models.PickupBooking
|
||||
if err := query.Order("createdat DESC").Find(&bookings).Error; err != nil {
|
||||
@@ -1878,6 +1898,128 @@ func HubAutoAssign(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// defaultBatchAssignCapPerRider caps how many bookings one rider can receive
|
||||
// in a single HubBatchAssign run, so the greedy pass doesn't pile every
|
||||
// pending pickup onto whichever rider happens to be closest to the first one.
|
||||
const defaultBatchAssignCapPerRider = 5
|
||||
|
||||
// batchRiderCandidate is a rider available for HubBatchAssign to consider,
|
||||
// tracked with a per-run assigned count so the cap can be enforced without a
|
||||
// DB round trip per booking.
|
||||
type batchRiderCandidate struct {
|
||||
userid int
|
||||
lat, lon float64
|
||||
assigned int
|
||||
}
|
||||
|
||||
// HubBatchAssign is a greedy nearest-available-rider batch dispatcher: for
|
||||
// every pending, unassigned booking at this hub (oldest first), it assigns
|
||||
// the closest rider who hasn't hit defaultBatchAssignCapPerRider yet in this
|
||||
// run, using the exact same transactional AssignMilerToBooking path
|
||||
// HubAssignMiler and AdminAssignMiler already use for one-at-a-time
|
||||
// assignment. This is a nearest-neighbor heuristic, not full multi-stop
|
||||
// route optimization (no route sequencing within a rider's stops, no
|
||||
// distance-matrix solver) — it answers "who's closest and free," not
|
||||
// "what's the optimal round for every rider." Good enough to clear a queue
|
||||
// of pending pickups without a human clicking through them one by one;
|
||||
// upgrade later if stop-sequencing quality becomes the actual bottleneck.
|
||||
func HubBatchAssign(c *fiber.Ctx) error {
|
||||
hubID := c.Locals("hubid").(int)
|
||||
staffID := c.Locals("userid").(int)
|
||||
prefix := hubPincodePrefix(hubID)
|
||||
|
||||
var req struct {
|
||||
Bookingids []int `json:"bookingids"`
|
||||
MaxPerRider int `json:"max_per_rider"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
capPerRider := req.MaxPerRider
|
||||
if capPerRider <= 0 {
|
||||
capPerRider = defaultBatchAssignCapPerRider
|
||||
}
|
||||
|
||||
bookingQuery := db.DB.Where("assignedmileruserid IS NULL AND status = ?", constants.BookingPendingPickup)
|
||||
if len(req.Bookingids) > 0 {
|
||||
bookingQuery = bookingQuery.Where("bookingid IN ?", req.Bookingids)
|
||||
} else if prefix != "" {
|
||||
bookingQuery = bookingQuery.Where("pickuppincode LIKE ?", prefix+"%")
|
||||
}
|
||||
bookingQuery = scopeBookingsToOwnTenant(c, bookingQuery)
|
||||
|
||||
var bookings []models.PickupBooking
|
||||
if err := bookingQuery.Order("createdat ASC").Find(&bookings).Error; err != nil {
|
||||
return utils.Internal(c, "failed to fetch pending bookings")
|
||||
}
|
||||
if len(bookings) == 0 {
|
||||
return utils.OK(c, fiber.Map{"assigned": 0, "skipped": 0, "results": []fiber.Map{}})
|
||||
}
|
||||
|
||||
var riderProfiles []models.MilerProfile
|
||||
if err := db.DB.Where("hubid = ? AND availabilitystatus = ?", hubID, constants.MilerAvailable).
|
||||
Find(&riderProfiles).Error; err != nil {
|
||||
return utils.Internal(c, "failed to fetch available riders")
|
||||
}
|
||||
|
||||
candidates := make([]*batchRiderCandidate, 0, len(riderProfiles))
|
||||
for _, mp := range riderProfiles {
|
||||
candidates = append(candidates, &batchRiderCandidate{
|
||||
userid: mp.Userid, lat: mp.Currentlatitude, lon: mp.Currentlongitude,
|
||||
})
|
||||
}
|
||||
|
||||
results := make([]fiber.Map, 0, len(bookings))
|
||||
assignedCount, skippedCount := 0, 0
|
||||
|
||||
for _, b := range bookings {
|
||||
var nearest *batchRiderCandidate
|
||||
nearestDist := math.MaxFloat64
|
||||
|
||||
for _, cand := range candidates {
|
||||
if cand.assigned >= capPerRider {
|
||||
continue
|
||||
}
|
||||
d := haversineKM(b.Pickuplatitude, b.Pickuplongitude, cand.lat, cand.lon)
|
||||
if d < nearestDist {
|
||||
nearestDist = d
|
||||
nearest = cand
|
||||
}
|
||||
}
|
||||
|
||||
if nearest == nil {
|
||||
results = append(results, fiber.Map{
|
||||
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
|
||||
"assigned": false, "reason": "no available rider under capacity",
|
||||
})
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := AssignMilerToBooking(b.Bookingid, nearest.userid, &staffID); err != nil {
|
||||
results = append(results, fiber.Map{
|
||||
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
|
||||
"assigned": false, "reason": err.Error(),
|
||||
})
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
nearest.assigned++
|
||||
results = append(results, fiber.Map{
|
||||
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
|
||||
"assigned": true, "mileruserid": nearest.userid, "distance_km": nearestDist,
|
||||
})
|
||||
assignedCount++
|
||||
}
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
"assigned": assignedCount,
|
||||
"skipped": skippedCount,
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// HUB REPORT EXPORT
|
||||
// --------------------
|
||||
|
||||
@@ -376,6 +376,91 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// MilerSkipDelivery records a failed/incomplete final-mile delivery attempt
|
||||
// (customer unavailable, gate locked, wrong address, etc.) without closing
|
||||
// out the consignment — the miler still holds the parcel, the consignment
|
||||
// stays Out_for_Delivery, and the attempt is logged for a retry. This is the
|
||||
// "skipped" half of the old system's 8-in-1 status endpoint; MilerDeliverConsignment
|
||||
// above is the "delivered" half, and MilerCancelAssignment (milerController.go)
|
||||
// is the pre-pickup "cancelled"/"rejected" half.
|
||||
func MilerSkipDelivery(c *fiber.Ctx) error {
|
||||
milerUserID := c.Locals("userid").(int)
|
||||
consignmentID, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return utils.BadRequest(c, "invalid consignment ID")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
if req.Reason == "" {
|
||||
return utils.BadRequest(c, "reason is required")
|
||||
}
|
||||
|
||||
var consignment models.Consignment
|
||||
if err := db.DB.First(&consignment, consignmentID).Error; err != nil {
|
||||
return utils.NotFound(c, "consignment not found")
|
||||
}
|
||||
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.Where("consignmentid = ? AND assignedmileruserid = ?", consignment.Consignmentid, milerUserID).
|
||||
First(&booking).Error; err != nil {
|
||||
return utils.NotFound(c, "assigned consignment not found")
|
||||
}
|
||||
|
||||
if consignment.Status != constants.ConsignmentOutForDelivery {
|
||||
return utils.BadRequest(c, "consignment is not out for delivery")
|
||||
}
|
||||
|
||||
tx := db.DB.Begin()
|
||||
|
||||
consignment.Attemptcount += 1
|
||||
consignment.Updatedat = time.Now()
|
||||
if err := tx.Save(&consignment).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to record skipped attempt")
|
||||
}
|
||||
|
||||
history := models.ConsignmentHistory{
|
||||
Consignmentid: consignment.Consignmentid,
|
||||
Userid: &milerUserID,
|
||||
Eventstatus: "Delivery_Skipped",
|
||||
Remarks: fmt.Sprintf("Attempt %d skipped at (%.5f, %.5f): %s", consignment.Attemptcount, req.Lat, req.Lon, req.Reason),
|
||||
}
|
||||
if err := tx.Create(&history).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to log skipped attempt")
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return utils.Internal(c, "failed to commit skipped attempt")
|
||||
}
|
||||
|
||||
// After 3 failed attempts, flag it for hub attention rather than leaving
|
||||
// it silently retrying forever.
|
||||
if consignment.Attemptcount >= 3 {
|
||||
exception := models.ConsignmentException{
|
||||
Consignmentid: consignment.Consignmentid,
|
||||
Reportedbyuserid: &milerUserID,
|
||||
Exceptiontype: constants.ExceptionUndeliverable,
|
||||
Severity: "Medium",
|
||||
Description: fmt.Sprintf("3 delivery attempts failed. Last reason: %s", req.Reason),
|
||||
}
|
||||
db.DB.Create(&exception)
|
||||
}
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
"consignmentid": consignment.Consignmentid,
|
||||
"attemptcount": consignment.Attemptcount,
|
||||
"status": consignment.Status,
|
||||
})
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// EARNINGS
|
||||
// --------------------
|
||||
|
||||
@@ -134,6 +134,48 @@ func VerifyMilerPin(cfg *config.Config) fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// ResetMilerPin lets a miler who forgot their PIN set a new one from just
|
||||
// their phone number, matching ResetCustomerPin's flow exactly (protected
|
||||
// only by authThrottle at the route level, same as the customer version —
|
||||
// no OTP verification wired in here either, consistent with the existing
|
||||
// pattern rather than a change to it).
|
||||
func ResetMilerPin(c *fiber.Ctx) error {
|
||||
req := new(dto.MilerResetPinRequest)
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
|
||||
if req.Phone == "" || req.NewPin == "" {
|
||||
return utils.BadRequest(c, "phone and new_pin are required")
|
||||
}
|
||||
|
||||
configID := req.Configid
|
||||
if configID == 0 {
|
||||
configID = 1001
|
||||
}
|
||||
|
||||
var user models.AppUser
|
||||
if err := db.DB.Where("contactno = ? AND configid = ?", req.Phone, configID).First(&user).Error; err != nil {
|
||||
return utils.NotFound(c, "no miler account found for this phone number")
|
||||
}
|
||||
|
||||
if user.Roleid != 5 {
|
||||
return utils.Forbidden(c, "this endpoint is restricted to miler accounts")
|
||||
}
|
||||
|
||||
pinHash, err := utils.HashPassword(req.NewPin)
|
||||
if err != nil {
|
||||
return utils.Internal(c, "failed to process PIN reset")
|
||||
}
|
||||
|
||||
user.Password = pinHash
|
||||
if err := db.DB.Save(&user).Error; err != nil {
|
||||
return utils.Internal(c, "failed to reset PIN")
|
||||
}
|
||||
|
||||
return utils.Message(c, "PIN reset successfully")
|
||||
}
|
||||
|
||||
func GetMilerProfile(c *fiber.Ctx) error {
|
||||
milerUserID := c.Locals("userid").(int)
|
||||
|
||||
@@ -443,6 +485,84 @@ func RejectMilerAssignment(c *fiber.Ctx) error {
|
||||
return utils.Message(c, "assignment rejected")
|
||||
}
|
||||
|
||||
// MilerCancelAssignment lets a miler back out of a booking they've already
|
||||
// accepted but not yet picked up (vehicle breakdown, can't reach the
|
||||
// address, etc.). Distinct from RejectMilerAssignment, which only applies
|
||||
// before acceptance — once the parcel is picked up the booking has become a
|
||||
// consignment and this no longer applies (use MilerSkipDelivery instead for
|
||||
// a failed delivery attempt on an in-flight consignment). Releases the
|
||||
// booking for reassignment the same way RejectMilerAssignment does.
|
||||
func MilerCancelAssignment(c *fiber.Ctx) error {
|
||||
milerUserID := c.Locals("userid").(int)
|
||||
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
||||
if err != nil {
|
||||
return utils.BadRequest(c, "invalid booking ID")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
if req.Reason == "" {
|
||||
req.Reason = "Cancelled by miler"
|
||||
}
|
||||
|
||||
tx := db.DB.Begin()
|
||||
|
||||
var booking models.PickupBooking
|
||||
if err := tx.Where("bookingid = ? AND assignedmileruserid = ?", bookingID, milerUserID).First(&booking).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.NotFound(c, "assigned booking not found")
|
||||
}
|
||||
|
||||
if booking.Status == constants.BookingPickedUp || booking.Status == constants.BookingConvertedConsignment {
|
||||
tx.Rollback()
|
||||
return utils.BadRequest(c, "booking cannot be cancelled after pickup — the parcel is already in the network")
|
||||
}
|
||||
|
||||
var ba models.BookingAssignment
|
||||
if err := tx.Where("bookingid = ? AND mileruserid = ? AND assignmentstatus = ?",
|
||||
bookingID, milerUserID, constants.AssignmentAccepted).First(&ba).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.BadRequest(c, "no accepted assignment found for this booking")
|
||||
}
|
||||
|
||||
ba.Assignmentstatus = constants.AssignmentCancelled
|
||||
ba.Remarks = req.Reason
|
||||
if err := tx.Save(&ba).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to cancel assignment")
|
||||
}
|
||||
|
||||
booking.Status = constants.BookingCreated
|
||||
booking.Assignedmileruserid = nil
|
||||
booking.Updatedat = time.Now()
|
||||
if err := tx.Save(&booking).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to release booking")
|
||||
}
|
||||
|
||||
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
||||
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to update miler availability")
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return utils.Internal(c, "failed to commit cancellation")
|
||||
}
|
||||
|
||||
if booking.Bookingsource == "CRM_Console" {
|
||||
go assignment.AssignCRMMiler(booking.Bookingid)
|
||||
} else {
|
||||
go assignment.AssignCustomerMiler(booking.Bookingid)
|
||||
}
|
||||
|
||||
return utils.Message(c, "assignment cancelled and released for reassignment")
|
||||
}
|
||||
|
||||
func BookingReachedCustomer(c *fiber.Ctx) error {
|
||||
milerUserID := c.Locals("userid").(int)
|
||||
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
||||
@@ -665,9 +785,20 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
||||
consignmentStatus = constants.ConsignmentOutForDelivery
|
||||
}
|
||||
|
||||
// The consignment's tenant is the booking's own tenant (set explicitly at
|
||||
// CreateExpressBooking time), not the completing miler's tenantid claim — a
|
||||
// miler can carry parcels for tenants other than their own, and using
|
||||
// their JWT tenantid here mis-attributed every such consignment. Falls
|
||||
// back to the miler's own tenantid only for B2C bookings that don't carry
|
||||
// one yet, matching the previous behavior for that case.
|
||||
consignmentTenantID := c.Locals("tenantid").(int)
|
||||
if booking.Tenantid != nil {
|
||||
consignmentTenantID = *booking.Tenantid
|
||||
}
|
||||
|
||||
consignment := models.Consignment{
|
||||
Trackingno: trackingNo,
|
||||
Tenantid: c.Locals("tenantid").(int),
|
||||
Tenantid: consignmentTenantID,
|
||||
Pickuplatitude: booking.Pickuplatitude,
|
||||
Pickuplongitude: booking.Pickuplongitude,
|
||||
Deliverylatitude: booking.Deliverylatitude,
|
||||
|
||||
Reference in New Issue
Block a user