package controllers import ( "fmt" "strconv" "strings" "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") } // IST wall-clock (DBNow) to match the other consignment timestamps. A single // transaction so a failed audit insert can't leave a state change with no // history row and still answer 200. now := utils.DBNow() tx := db.DB.Begin() 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 := tx.Create(&exception).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to raise handover exception") } if err := tx.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), }).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to record dispute history") } if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to record the dispute") } 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 := tx.Save(&consignment).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to record receipt") } if !alreadyInwarded { remarks := strings.TrimSpace(req.Remarks) if remarks == "" { remarks = "Physical receipt confirmed at base" } if err := tx.Create(&models.ConsignmentHistory{ Consignmentid: consignment.Consignmentid, Hubid: &hubID, Eventstatus: constants.ConsignmentInwardedAtHub, Remarks: fmt.Sprintf("%s (hub staff account %d)", remarks, staffAccountID), }).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to record receipt history") } } if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to record the receipt") } return utils.OK(c, fiber.Map{ "consignmentid": consignment.Consignmentid, "trackingno": consignment.Trackingno, "consignmentstatus": consignment.Status, "inwardedat": consignment.Inwardedat, "received": true, "already_received": alreadyInwarded, }) }