Files
doormile_backend/controllers/hubInboundController.go

287 lines
10 KiB
Go

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,
})
}