287 lines
10 KiB
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,
|
|
})
|
|
}
|