updates on the api endpoints on the customer page and more
This commit is contained in:
286
controllers/hubInboundController.go
Normal file
286
controllers/hubInboundController.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// --------------------
|
||||
// BASE INBOUND — what is on its way in, and confirming it arrived
|
||||
//
|
||||
// The console side of the rider handover. Wire vocabulary stays hub
|
||||
// (Inwarded_at_Hub, currenthubid); the rider app renders it as Base.
|
||||
// --------------------
|
||||
|
||||
// scopeConsignmentsToOwnTenant is the consignment counterpart of
|
||||
// scopeBookingsToOwnTenant: partner-tenant hub staff see only their own tenant's
|
||||
// parcels, Doormile staff see everything. Same rule, different table — without
|
||||
// it a partner's staff would read every other client's parcels passing through
|
||||
// the same base.
|
||||
func scopeConsignmentsToOwnTenant(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)
|
||||
}
|
||||
|
||||
// canHubStaffAccessConsignment proves ownership of a consignment addressed by id
|
||||
// before it is written to, rather than trusting the path parameter.
|
||||
func canHubStaffAccessConsignment(c *fiber.Ctx, cn *models.Consignment) bool {
|
||||
staff, err := getCurrentHubStaff(c)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if isDoormileStaff(staff) {
|
||||
return true
|
||||
}
|
||||
return staff.Tenantid != nil && cn.Tenantid == *staff.Tenantid
|
||||
}
|
||||
|
||||
// GetHubInboundExpected lists parcels a rider is currently carrying towards this
|
||||
// base — collected, routed here, not yet handed over. Between a rider collecting
|
||||
// an intercity parcel and inwarding it, nobody at the destination base could see
|
||||
// it was coming; this is that view.
|
||||
//
|
||||
// It reads consignments on Created — collected, in a rider's hands, with a base
|
||||
// as the next leg — whose current base is this one. Under the compatibility flow
|
||||
// a hub-routed parcel is marked Inwarded_at_Hub at pickup and so never appears
|
||||
// here; that is expected, and GetHubInboundToday covers those.
|
||||
func GetHubInboundExpected(c *fiber.Ctx) error {
|
||||
hubID := c.Locals("hubid").(int)
|
||||
|
||||
query := db.DB.Where("currenthubid = ? AND status = ? AND deletedat IS NULL",
|
||||
hubID, constants.ConsignmentCreated)
|
||||
query = scopeConsignmentsToOwnTenant(c, query)
|
||||
|
||||
var consignments []models.Consignment
|
||||
if err := query.Order("createdat DESC").Find(&consignments).Error; err != nil {
|
||||
return utils.Internal(c, "failed to fetch expected inbound consignments")
|
||||
}
|
||||
|
||||
// Batched lookups — this stays a handful of queries however many parcels are
|
||||
// in flight towards the base.
|
||||
ids := make([]int, 0, len(consignments))
|
||||
for _, cs := range consignments {
|
||||
ids = append(ids, cs.Consignmentid)
|
||||
}
|
||||
bookingByConsignment := map[int]models.PickupBooking{}
|
||||
riderIDs := []int{}
|
||||
customerIDs := []int{}
|
||||
if len(ids) > 0 {
|
||||
var bookings []models.PickupBooking
|
||||
db.DB.Where("consignmentid IN ?", ids).Find(&bookings)
|
||||
for _, b := range bookings {
|
||||
if b.Consignmentid != nil {
|
||||
bookingByConsignment[*b.Consignmentid] = b
|
||||
}
|
||||
if b.Assignedmileruserid != nil {
|
||||
riderIDs = append(riderIDs, *b.Assignedmileruserid)
|
||||
}
|
||||
customerIDs = append(customerIDs, b.Appcustomerid)
|
||||
}
|
||||
}
|
||||
riderByID := map[int]models.AppUser{}
|
||||
if len(riderIDs) > 0 {
|
||||
var riders []models.AppUser
|
||||
db.DB.Where("userid IN ?", riderIDs).Find(&riders)
|
||||
for _, r := range riders {
|
||||
riderByID[r.Userid] = r
|
||||
}
|
||||
}
|
||||
customerByID := map[int]models.AppCustomer{}
|
||||
if len(customerIDs) > 0 {
|
||||
var customers []models.AppCustomer
|
||||
db.DB.Where("appcustomerid IN ?", customerIDs).Find(&customers)
|
||||
for _, cu := range customers {
|
||||
customerByID[cu.Appcustomerid] = cu
|
||||
}
|
||||
}
|
||||
|
||||
response := make([]fiber.Map, 0, len(consignments))
|
||||
for i := range consignments {
|
||||
cs := consignments[i]
|
||||
row := fiber.Map{
|
||||
"consignmentid": cs.Consignmentid,
|
||||
"trackingno": cs.Trackingno,
|
||||
// destination_base is the base this parcel moves on to after here, and
|
||||
// is only known once something routes it onward — null more often than
|
||||
// not. The delivery pincode is the reliable statement of where it ends
|
||||
// up, so both are given rather than one standing in for the other.
|
||||
"destination_base": renderBase(loadHub(cs.Destinationhubid)),
|
||||
"final_destination": cs.Deliverypincode,
|
||||
"delivery_pincode": cs.Deliverypincode,
|
||||
"chargeableweight": cs.Chargeableweight,
|
||||
"current_state": cs.Status,
|
||||
"inbound_status": "expected",
|
||||
"next_action": nextActionForConsignment(cs.Status),
|
||||
"collected_at": cs.Createdat,
|
||||
"pickup_pincode": cs.Pickuppincode,
|
||||
}
|
||||
if b, ok := bookingByConsignment[cs.Consignmentid]; ok {
|
||||
customerName := ""
|
||||
if cu, ok := customerByID[b.Appcustomerid]; ok {
|
||||
customerName = strings.TrimSpace(cu.Firstname + " " + cu.Lastname)
|
||||
}
|
||||
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b, customerName)
|
||||
row["bookingid"] = b.Bookingid
|
||||
row["bookingno"] = b.Bookingno
|
||||
row["customer_name"] = customerName
|
||||
row["pickup_source_type"] = sourceType
|
||||
row["pickup_source_id"] = sourceID
|
||||
row["source"] = sourceName
|
||||
row["pickup_address"] = sourceAddress
|
||||
row["destination_address"] = b.Deliveryaddress
|
||||
if b.Assignedmileruserid != nil {
|
||||
row["rider_userid"] = *b.Assignedmileruserid
|
||||
if r, ok := riderByID[*b.Assignedmileruserid]; ok {
|
||||
row["rider"] = r.Authname
|
||||
row["rider_phone"] = r.Contactno
|
||||
}
|
||||
}
|
||||
}
|
||||
response = append(response, row)
|
||||
}
|
||||
|
||||
return utils.List(c, response, int64(len(response)))
|
||||
}
|
||||
|
||||
// ReconcileHubInbound is the base's side of the rider handover: staff either
|
||||
// confirm the parcel is physically here, or record that it never arrived despite
|
||||
// a rider marking it handed over.
|
||||
//
|
||||
// received=true is the ordinary case and is idempotent — confirming a parcel
|
||||
// that is already inwarded re-affirms it rather than failing, because staff
|
||||
// working through a pile will hit some rows twice.
|
||||
//
|
||||
// received=false is the reconciliation path. It deliberately does not quietly
|
||||
// move the parcel backwards: it raises an exception naming the discrepancy, so a
|
||||
// parcel a rider swears was handed over and staff never saw becomes a tracked
|
||||
// open item rather than a disagreement nobody owns.
|
||||
func ReconcileHubInbound(c *fiber.Ctx) error {
|
||||
hubID := c.Locals("hubid").(int)
|
||||
// HubStaffAuth sets userid to the hubstaffaccountid, which is NOT an
|
||||
// appusers.userid. consignmentexceptions.reportedbyuserid and
|
||||
// consignmenthistory.userid both carry a real FK to appusers(userid), so
|
||||
// writing a hub-staff id into either violates it — the row is rejected and the
|
||||
// whole action 500s. The staff identity goes into the free-text fields
|
||||
// instead, and the FK-bearing columns are left null. createdby on
|
||||
// consignmentexceptions carries no FK, so it can hold the staff id.
|
||||
staffAccountID, _ := c.Locals("userid").(int)
|
||||
consignmentID, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return utils.BadRequest(c, "invalid consignment ID")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
// Pointer so an omitted field is never read as "not received".
|
||||
Received *bool `json:"received"`
|
||||
Remarks string `json:"remarks"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
if req.Received == nil {
|
||||
return utils.BadRequest(c, "received is required (true = the parcel is physically here, false = it never arrived)")
|
||||
}
|
||||
|
||||
var consignment models.Consignment
|
||||
if err := db.DB.Where("consignmentid = ? AND deletedat IS NULL", consignmentID).
|
||||
First(&consignment).Error; err != nil {
|
||||
return utils.NotFound(c, "consignment not found")
|
||||
}
|
||||
if !canHubStaffAccessConsignment(c, &consignment) {
|
||||
return utils.NotFound(c, "consignment not found")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if !*req.Received {
|
||||
description := strings.TrimSpace(req.Remarks)
|
||||
if description == "" {
|
||||
description = "Rider recorded a handover at this base but the parcel was not physically received."
|
||||
}
|
||||
exception := models.ConsignmentException{
|
||||
Consignmentid: consignment.Consignmentid,
|
||||
Hubid: &hubID,
|
||||
// Lost is the closest type the consignmentexceptions CHECK constraint
|
||||
// already allows, and it is honest: a parcel recorded as handed over
|
||||
// that nobody can find is lost until it turns up. A dedicated
|
||||
// Handover_Not_Received type would need that constraint widened first.
|
||||
Exceptiontype: constants.ExceptionLost,
|
||||
Severity: "High",
|
||||
Description: fmt.Sprintf("%s (reported by hub staff account %d)", description, staffAccountID),
|
||||
Status: constants.ExceptionOpen,
|
||||
Createdby: staffAccountID,
|
||||
}
|
||||
if err := db.DB.Create(&exception).Error; err != nil {
|
||||
return utils.Internal(c, "failed to raise handover exception")
|
||||
}
|
||||
db.DB.Create(&models.ConsignmentHistory{
|
||||
Consignmentid: consignment.Consignmentid,
|
||||
Hubid: &hubID,
|
||||
Eventstatus: consignment.Status,
|
||||
Remarks: fmt.Sprintf("Handover disputed at base by hub staff account %d: %s",
|
||||
staffAccountID, description),
|
||||
})
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
"consignmentid": consignment.Consignmentid,
|
||||
"trackingno": consignment.Trackingno,
|
||||
"consignmentstatus": consignment.Status,
|
||||
"received": false,
|
||||
"exceptionid": exception.Exceptionid,
|
||||
"exceptiontype": exception.Exceptiontype,
|
||||
})
|
||||
}
|
||||
|
||||
alreadyInwarded := consignment.Status == constants.ConsignmentInwardedAtHub
|
||||
if !alreadyInwarded {
|
||||
consignment.Status = constants.ConsignmentInwardedAtHub
|
||||
consignment.Currenthubid = &hubID
|
||||
if consignment.Originhubid == nil {
|
||||
consignment.Originhubid = &hubID
|
||||
}
|
||||
consignment.Updatedat = now
|
||||
consignment.Updatedby = staffAccountID
|
||||
}
|
||||
if consignment.Inwardedat == nil {
|
||||
consignment.Inwardedat = &now
|
||||
}
|
||||
if err := db.DB.Save(&consignment).Error; err != nil {
|
||||
return utils.Internal(c, "failed to record receipt")
|
||||
}
|
||||
|
||||
if !alreadyInwarded {
|
||||
remarks := strings.TrimSpace(req.Remarks)
|
||||
if remarks == "" {
|
||||
remarks = "Physical receipt confirmed at base"
|
||||
}
|
||||
db.DB.Create(&models.ConsignmentHistory{
|
||||
Consignmentid: consignment.Consignmentid,
|
||||
Hubid: &hubID,
|
||||
Eventstatus: constants.ConsignmentInwardedAtHub,
|
||||
Remarks: fmt.Sprintf("%s (hub staff account %d)", remarks, staffAccountID),
|
||||
})
|
||||
}
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
"consignmentid": consignment.Consignmentid,
|
||||
"trackingno": consignment.Trackingno,
|
||||
"consignmentstatus": consignment.Status,
|
||||
"inwardedat": consignment.Inwardedat,
|
||||
"received": true,
|
||||
"already_received": alreadyInwarded,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user