564 lines
20 KiB
Go
564 lines
20 KiB
Go
package controllers
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"doormile/constants"
|
|
"doormile/db"
|
|
"doormile/models"
|
|
"doormile/utils"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
// Catalogue and configuration — §5 of the customer contract.
|
|
//
|
|
// These four reads drive the whole booking form; nothing else in the app works
|
|
// without them. Every one of them degrades to something the app can still
|
|
// render rather than to an error, because a customer staring at a retry button
|
|
// on the state picker cannot book at all.
|
|
|
|
// ── Serviceability ───────────────────────────────────────────────────────────
|
|
|
|
// GetCxStates lists the states a destination may be sent to.
|
|
//
|
|
// districtCount counts AVAILABLE districts only, and the client hides any state
|
|
// showing 0 — so a state that is listed but has nothing open still returns,
|
|
// carrying the "Opening soon" transit tag. An empty list is a legitimate
|
|
// answer: the app has a designed no-service state for it.
|
|
func GetCxStates(c *fiber.Ctx) error {
|
|
var states []models.ServiceableState
|
|
if err := db.DB.Where("status = ?", "Active").
|
|
Order("displayorder ASC, statename ASC").Find(&states).Error; err != nil {
|
|
utils.Error("GetCxStates: query failed", "error", err)
|
|
return utils.CxInternal(c)
|
|
}
|
|
|
|
// One grouped count instead of a query per state.
|
|
type stateCount struct {
|
|
Statecode string
|
|
N int
|
|
}
|
|
var counts []stateCount
|
|
if err := db.DB.Model(&models.ServiceableDistrict{}).
|
|
Select("statecode, count(*) as n").
|
|
Where("available = ?", true).
|
|
Group("statecode").Scan(&counts).Error; err != nil {
|
|
utils.Warn("GetCxStates: district count failed, reporting zero", "error", err)
|
|
}
|
|
byState := make(map[string]int, len(counts))
|
|
for _, sc := range counts {
|
|
byState[sc.Statecode] = sc.N
|
|
}
|
|
|
|
out := make([]fiber.Map, 0, len(states))
|
|
for _, s := range states {
|
|
out = append(out, fiber.Map{
|
|
"code": s.Statecode,
|
|
"name": s.Statename,
|
|
"districtCount": byState[s.Statecode],
|
|
"transitTag": s.Transittag,
|
|
})
|
|
}
|
|
|
|
if served := serveIfNotModified(c, out); served {
|
|
return nil
|
|
}
|
|
return utils.CxList(c, out, len(out), nil)
|
|
}
|
|
|
|
// GetCxDistricts lists every district in a state, unavailable ones included.
|
|
//
|
|
// The picker filters unavailable districts out, but their names still appear in
|
|
// a quiet "coming soon" line — dropping them here would delete real copy from
|
|
// the screen. `note` says why one is closed, so the app never has to invent a
|
|
// reason.
|
|
func GetCxDistricts(c *fiber.Ctx) error {
|
|
stateCode := strings.ToUpper(strings.TrimSpace(c.Params("stateCode")))
|
|
if stateCode == "" {
|
|
return utils.CxBadRequest(c, "Pick a state first")
|
|
}
|
|
|
|
var state models.ServiceableState
|
|
if err := db.DB.Where("statecode = ? AND status = ?", stateCode, "Active").
|
|
First(&state).Error; err != nil {
|
|
return utils.CxNotFound(c, "That state is no longer serviceable")
|
|
}
|
|
|
|
var districts []models.ServiceableDistrict
|
|
if err := db.DB.Where("statecode = ?", stateCode).
|
|
Order("available DESC, displayorder ASC, districtname ASC").
|
|
Find(&districts).Error; err != nil {
|
|
utils.Error("GetCxDistricts: query failed", "state", stateCode, "error", err)
|
|
return utils.CxInternal(c)
|
|
}
|
|
|
|
hubNames := hubNamesFor(districts)
|
|
|
|
out := make([]fiber.Map, 0, len(districts))
|
|
for _, d := range districts {
|
|
row := fiber.Map{
|
|
"code": d.Districtcode,
|
|
"name": d.Districtname,
|
|
"available": d.Available,
|
|
}
|
|
if d.Note != "" {
|
|
row["note"] = d.Note
|
|
}
|
|
if d.Hubid != nil {
|
|
if name, ok := hubNames[*d.Hubid]; ok {
|
|
row["hub"] = name
|
|
}
|
|
}
|
|
if d.Promise != "" {
|
|
row["promise"] = d.Promise
|
|
}
|
|
// The district's centre. Only state and district are required at booking
|
|
// time, so for most destinations this is the ONLY geography the parcel
|
|
// has until the miler corrects it at the door — it is what places the
|
|
// destination on a map and what the fare estimate is priced against.
|
|
// Omitted rather than sent as 0,0 when unknown: null island is a real
|
|
// coordinate and would render as a pin off the coast of Africa.
|
|
if d.Centrelatitude != 0 || d.Centrelongitude != 0 {
|
|
row["lat"] = d.Centrelatitude
|
|
row["lng"] = d.Centrelongitude
|
|
}
|
|
out = append(out, row)
|
|
}
|
|
|
|
if served := serveIfNotModified(c, out); served {
|
|
return nil
|
|
}
|
|
return utils.CxList(c, out, len(out), nil)
|
|
}
|
|
|
|
// hubNamesFor resolves the serving-hub names for a page of districts in one
|
|
// query rather than one per row.
|
|
func hubNamesFor(districts []models.ServiceableDistrict) map[int]string {
|
|
ids := make([]int, 0, len(districts))
|
|
seen := map[int]bool{}
|
|
for _, d := range districts {
|
|
if d.Hubid != nil && !seen[*d.Hubid] {
|
|
seen[*d.Hubid] = true
|
|
ids = append(ids, *d.Hubid)
|
|
}
|
|
}
|
|
names := make(map[int]string, len(ids))
|
|
if len(ids) == 0 {
|
|
return names
|
|
}
|
|
var hubs []models.Hub
|
|
if err := db.DB.Select("hubid, hubname").Where("hubid IN ?", ids).Find(&hubs).Error; err != nil {
|
|
utils.Warn("hubNamesFor: hub lookup failed, omitting hub names", "error", err)
|
|
return names
|
|
}
|
|
for _, h := range hubs {
|
|
names[h.Hubid] = h.Hubname
|
|
}
|
|
return names
|
|
}
|
|
|
|
// serveIfNotModified implements ETag/If-None-Match for the serviceability
|
|
// reads. Both change perhaps weekly and are fetched on every cold start of the
|
|
// booking form, so a 304 is the difference between a full round trip and a
|
|
// header exchange. Returns true when it has already answered.
|
|
func serveIfNotModified(c *fiber.Ctx, payload interface{}) bool {
|
|
body, err := c.App().Config().JSONEncoder(payload)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
sum := sha256.Sum256(body)
|
|
etag := `"` + hex.EncodeToString(sum[:16]) + `"`
|
|
c.Set("ETag", etag)
|
|
c.Set("Cache-Control", "max-age=300")
|
|
|
|
// A client may legitimately send several etags, or the weak form.
|
|
for _, candidate := range strings.Split(c.Get("If-None-Match"), ",") {
|
|
candidate = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(candidate), "W/"))
|
|
if candidate == etag || candidate == "*" {
|
|
c.Status(fiber.StatusNotModified)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ── Pickup slots ─────────────────────────────────────────────────────────────
|
|
|
|
const (
|
|
// cxSlotLeadMinutes is how far ahead of a window's start the app may still
|
|
// offer it. A window that starts in four minutes cannot be staffed, and
|
|
// offering it produces a booking nobody can reach on time.
|
|
cxSlotLeadMinutes = 45
|
|
// cxSlotDays is how far ahead slots are offered: today and tomorrow, which
|
|
// is what the design lays out.
|
|
cxSlotDays = 2
|
|
// cxSlotZoneRadiusKM bounds "the customer's zone" when counting how full a
|
|
// window already is. Capacity is a property of an area's riders, not of the
|
|
// whole city.
|
|
cxSlotZoneRadiusKM = 12.0
|
|
// cxMilersNearbyRadiusKM is the radius for the reassuring "4 milers nearby"
|
|
// line — deliberately tighter than the capacity radius, because it is a
|
|
// statement about who could arrive shortly.
|
|
cxMilersNearbyRadiusKM = 6.0
|
|
)
|
|
|
|
// GetCxPickupSlots returns the pickup windows offered at a location.
|
|
//
|
|
// Slots are capacity- and location-aware: the id resolves to a real
|
|
// preferredpickupfrom/to on the booking, which is what the assignment engine
|
|
// consumes, so a slot the customer can pick is a slot ops can staff. Windows
|
|
// already past, or too close to start, are not returned at all rather than
|
|
// returned as unavailable — a greyed-out 8am slot at 6pm is noise.
|
|
func GetCxPickupSlots(c *fiber.Ctx) error {
|
|
lat, _ := strconv.ParseFloat(c.Query("lat"), 64)
|
|
lng, _ := strconv.ParseFloat(c.Query("lng"), 64)
|
|
|
|
appLocationID := appLocationForPoint(lat, lng)
|
|
|
|
var templates []models.PickupSlotTemplate
|
|
q := db.DB.Where("status = ?", "Active")
|
|
if appLocationID != nil {
|
|
q = q.Where("applocationid IS NULL OR applocationid = ?", *appLocationID)
|
|
} else {
|
|
q = q.Where("applocationid IS NULL")
|
|
}
|
|
if err := q.Order("displayorder ASC, starthour ASC").Find(&templates).Error; err != nil {
|
|
utils.Error("GetCxPickupSlots: template query failed", "error", err)
|
|
return utils.CxInternal(c)
|
|
}
|
|
|
|
now := utils.ISTNow()
|
|
cutoff := now.Add(cxSlotLeadMinutes * time.Minute)
|
|
|
|
candidates := make([]cxSlotCandidate, 0, len(templates)*cxSlotDays)
|
|
for day := 0; day < cxSlotDays; day++ {
|
|
d := now.AddDate(0, 0, day)
|
|
for _, tpl := range templates {
|
|
from := time.Date(d.Year(), d.Month(), d.Day(), tpl.Starthour, tpl.Startminute, 0, 0, utils.ISTLocation())
|
|
to := time.Date(d.Year(), d.Month(), d.Day(), tpl.Endhour, tpl.Endminute, 0, 0, utils.ISTLocation())
|
|
if !from.After(cutoff) {
|
|
continue
|
|
}
|
|
candidates = append(candidates, cxSlotCandidate{
|
|
id: cxSlotID(from, tpl.Code),
|
|
from: from,
|
|
to: to,
|
|
tpl: tpl,
|
|
})
|
|
}
|
|
}
|
|
|
|
booked := slotLoad(candidates, lat, lng)
|
|
milersNearby := milersWithin(lat, lng, cxMilersNearbyRadiusKM)
|
|
|
|
out := make([]fiber.Map, 0, len(candidates))
|
|
taggedOne := false
|
|
for _, cand := range candidates {
|
|
remaining := cand.tpl.Capacity - booked[cand.id]
|
|
available := remaining > 0
|
|
|
|
row := fiber.Map{
|
|
"id": cand.id,
|
|
"day": utils.FormatISTDay(cand.from),
|
|
"window": utils.FormatISTWindow(cand.from, cand.to),
|
|
"available": available,
|
|
}
|
|
if !available {
|
|
row["note"] = "Fully booked"
|
|
}
|
|
// At most one slot carries the tag, and only if it can actually be
|
|
// booked — labelling a full window "Fastest pickup" is worse than
|
|
// labelling nothing.
|
|
if available && !taggedOne && cand.tpl.Tag != "" {
|
|
row["tag"] = cand.tpl.Tag
|
|
taggedOne = true
|
|
}
|
|
if milersNearby > 0 {
|
|
row["milersNearby"] = milersNearby
|
|
}
|
|
if available && cand.tpl.Caption != "" {
|
|
row["caption"] = cand.tpl.Caption
|
|
}
|
|
out = append(out, row)
|
|
}
|
|
|
|
// Slots are volatile; a stale slot list is a booking that 409s on confirm.
|
|
c.Set("Cache-Control", "max-age=30")
|
|
return utils.CxList(c, out, len(out), nil)
|
|
}
|
|
|
|
// cxSlotID mints the opaque slot id the client sends back. It encodes the date
|
|
// and the template code so the server can resolve it to a real window without
|
|
// keeping per-request state — and so a slot id from yesterday's cached list
|
|
// resolves to yesterday and is rejected, rather than silently booking today.
|
|
func cxSlotID(from time.Time, code string) string {
|
|
return fmt.Sprintf("slot_%s_%s", from.Format("20060102"), code)
|
|
}
|
|
|
|
// ResolveCxSlot turns a slot id back into the window it names, checking the
|
|
// template still exists and is active. Returns ok=false for an unknown,
|
|
// malformed or retired slot.
|
|
func ResolveCxSlot(slotID string) (from, to time.Time, ok bool) {
|
|
parts := strings.SplitN(slotID, "_", 3)
|
|
if len(parts) != 3 || parts[0] != "slot" {
|
|
return time.Time{}, time.Time{}, false
|
|
}
|
|
day, err := time.ParseInLocation("20060102", parts[1], utils.ISTLocation())
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, false
|
|
}
|
|
|
|
var tpl models.PickupSlotTemplate
|
|
if err := db.DB.Where("code = ? AND status = ?", parts[2], "Active").
|
|
First(&tpl).Error; err != nil {
|
|
return time.Time{}, time.Time{}, false
|
|
}
|
|
|
|
from = time.Date(day.Year(), day.Month(), day.Day(), tpl.Starthour, tpl.Startminute, 0, 0, utils.ISTLocation())
|
|
to = time.Date(day.Year(), day.Month(), day.Day(), tpl.Endhour, tpl.Endminute, 0, 0, utils.ISTLocation())
|
|
return from, to, true
|
|
}
|
|
|
|
// CxSlotDateIsPast reports whether a slot id names a day that is already over.
|
|
//
|
|
// Pure: it reads the date out of the id and compares it to today, with no
|
|
// template lookup and no database. That matters because it is the cheapest
|
|
// validation in the booking path and it catches the most likely stale-slot
|
|
// case — an app left open across midnight, or one that cached the slot list for
|
|
// a whole session, sending yesterday's window in good faith.
|
|
//
|
|
// Only a whole day in the past is decided here. Whether one of TODAY's windows
|
|
// has already started needs the template's hours, which ResolveCxSlot loads.
|
|
func CxSlotDateIsPast(slotID string) bool {
|
|
parts := strings.SplitN(slotID, "_", 3)
|
|
if len(parts) != 3 || parts[0] != "slot" {
|
|
return false
|
|
}
|
|
day, err := time.ParseInLocation("20060102", parts[1], utils.ISTLocation())
|
|
if err != nil {
|
|
return false
|
|
}
|
|
now := utils.ISTNow()
|
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, utils.ISTLocation())
|
|
return day.Before(today)
|
|
}
|
|
|
|
// CxSlotHasCapacity re-checks a window at confirm time. The list read is
|
|
// advisory and up to 30 seconds stale; this is the authority, and it is what
|
|
// turns a race into a clean 409 rather than an overbooked window.
|
|
func CxSlotHasCapacity(slotID string, lat, lng float64) bool {
|
|
from, to, ok := ResolveCxSlot(slotID)
|
|
if !ok {
|
|
return false
|
|
}
|
|
var tpl models.PickupSlotTemplate
|
|
parts := strings.SplitN(slotID, "_", 3)
|
|
if err := db.DB.Where("code = ?", parts[2]).First(&tpl).Error; err != nil {
|
|
return false
|
|
}
|
|
return countBookingsInWindow(from, to, lat, lng) < tpl.Capacity
|
|
}
|
|
|
|
// cxSlotCandidate is one concrete window on one concrete day, before capacity
|
|
// is applied — a template plus the date it was expanded onto.
|
|
type cxSlotCandidate struct {
|
|
id string
|
|
from, to time.Time
|
|
tpl models.PickupSlotTemplate
|
|
}
|
|
|
|
// slotLoad counts how many live bookings already sit in each candidate window
|
|
// near this point. Done per window rather than in one grouped query because the
|
|
// windows overlap across days and the zone filter is geometric, not indexable
|
|
// here; the list is at most a dozen rows.
|
|
func slotLoad(candidates []cxSlotCandidate, lat, lng float64) map[string]int {
|
|
load := make(map[string]int, len(candidates))
|
|
for _, cand := range candidates {
|
|
load[cand.id] = countBookingsInWindow(cand.from, cand.to, lat, lng)
|
|
}
|
|
return load
|
|
}
|
|
|
|
// countBookingsInWindow counts pickups already committed to a window inside the
|
|
// customer's zone. Cancelled and completed bookings do not consume capacity —
|
|
// only work still to be done does.
|
|
func countBookingsInWindow(from, to time.Time, lat, lng float64) int {
|
|
// Stored timestamps are IST wall clock (see utils.DBNow), so the bounds are
|
|
// sent as those digits rather than as a UTC instant. Comparing a UTC clock
|
|
// against IST-stamped rows is what made date-range reports undercount.
|
|
fromDB := time.Date(from.Year(), from.Month(), from.Day(), from.Hour(), from.Minute(), 0, 0, time.UTC)
|
|
toDB := time.Date(to.Year(), to.Month(), to.Day(), to.Hour(), to.Minute(), 0, 0, time.UTC)
|
|
|
|
type row struct {
|
|
Pickuplatitude float64
|
|
Pickuplongitude float64
|
|
}
|
|
var rows []row
|
|
err := db.DB.Model(&models.PickupBooking{}).
|
|
Select("pickuplatitude, pickuplongitude").
|
|
Where("preferredpickupfrom >= ? AND preferredpickupfrom < ?", fromDB, toDB).
|
|
Where("status NOT IN ?", []string{constants.BookingCancelled, constants.BookingConvertedConsignment}).
|
|
Find(&rows).Error
|
|
if err != nil {
|
|
// Failing open keeps the form usable. An over-filled window is an ops
|
|
// problem; a booking form that cannot offer any slot is a dead app.
|
|
utils.Warn("countBookingsInWindow: query failed, treating window as open", "error", err)
|
|
return 0
|
|
}
|
|
|
|
if lat == 0 && lng == 0 {
|
|
return len(rows)
|
|
}
|
|
n := 0
|
|
for _, r := range rows {
|
|
if r.Pickuplatitude == 0 && r.Pickuplongitude == 0 {
|
|
continue
|
|
}
|
|
if calculateDistance(lat, lng, r.Pickuplatitude, r.Pickuplongitude) <= cxSlotZoneRadiusKM {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// milersWithin counts riders currently reporting a position inside a radius.
|
|
// Reads the same Redis GEO index the assignment engine searches, so the number
|
|
// the customer is shown is the pool the dispatcher would actually draw from.
|
|
// Returns 0 when Redis is unavailable, and the client hides the line on 0.
|
|
func milersWithin(lat, lng, radiusKM float64) int {
|
|
if db.Rdb == nil || (lat == 0 && lng == 0) {
|
|
return 0
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
locs, err := db.Rdb.GeoSearchLocation(ctx, "milers:locations", &redis.GeoSearchLocationQuery{
|
|
GeoSearchQuery: redis.GeoSearchQuery{
|
|
Longitude: lng,
|
|
Latitude: lat,
|
|
Radius: radiusKM,
|
|
RadiusUnit: "km",
|
|
Sort: "ASC",
|
|
Count: 50,
|
|
},
|
|
}).Result()
|
|
if err != nil {
|
|
utils.Warn("milersWithin: geo search failed", "error", err)
|
|
return 0
|
|
}
|
|
return len(locs)
|
|
}
|
|
|
|
// appLocationForPoint resolves which city a coordinate belongs to, via the
|
|
// nearest active hub. Nil when nothing is close enough to claim it, in which
|
|
// case only city-agnostic configuration applies.
|
|
func appLocationForPoint(lat, lng float64) *int {
|
|
if lat == 0 && lng == 0 {
|
|
return nil
|
|
}
|
|
var hubs []models.Hub
|
|
if err := db.DB.Select("hubid, applocationid, latitude, longitude").
|
|
Where("status = ? AND deletedat IS NULL", "Active").Find(&hubs).Error; err != nil {
|
|
utils.Warn("appLocationForPoint: hub query failed", "error", err)
|
|
return nil
|
|
}
|
|
best := -1.0
|
|
var bestID *int
|
|
for i := range hubs {
|
|
h := hubs[i]
|
|
if h.Latitude == 0 && h.Longitude == 0 {
|
|
continue
|
|
}
|
|
d := calculateDistance(lat, lng, h.Latitude, h.Longitude)
|
|
if best < 0 || d < best {
|
|
best = d
|
|
id := h.Applocationid
|
|
bestID = &id
|
|
}
|
|
}
|
|
// A hub 200km away says nothing about which city this is.
|
|
if bestID == nil || best > 60 {
|
|
return nil
|
|
}
|
|
return bestID
|
|
}
|
|
|
|
// ── Booking limits ───────────────────────────────────────────────────────────
|
|
|
|
// cxDefaultMaxPackages / cxDefaultMaxDestinations are the last-resort values,
|
|
// used only when no configuration row exists at all. They can never be 0 —
|
|
// a zero cap would reject every booking on the platform.
|
|
//
|
|
// cxDefaultMaxDestinations is deliberately **1**, not the 5 the design allows.
|
|
//
|
|
// This is the fail-safe half of the multi-destination gate. The gate itself is a
|
|
// database value (customerbookinglimits.maxdestinations), and a gate that opens
|
|
// when its configuration is missing is not a gate: a migration that ran without
|
|
// the seed, a wiped table, or a fresh environment would silently permit
|
|
// multi-destination bookings that the deployed rider app cannot complete,
|
|
// stranding parcels with no stop and no way to close them.
|
|
//
|
|
// So "no configuration" resolves to the safest behaviour, not the most
|
|
// permissive. Ops raises it to 5 by inserting the row — an explicit act — once
|
|
// a rider build keying on consignmentid is live. The client's own 20/5 fallback
|
|
// is UI guidance only; this is the authority.
|
|
const (
|
|
cxDefaultMaxPackages = 20
|
|
cxDefaultMaxDestinations = 1
|
|
)
|
|
|
|
// GetCxBookingLimits returns the caps on a single pickup.
|
|
//
|
|
// Nothing in the UI hardcodes these; they live here so ops can vary them by
|
|
// city without an app release. Keyed off the pickup location when one is
|
|
// supplied, so the client can re-fetch when the pickup point moves.
|
|
func GetCxBookingLimits(c *fiber.Ctx) error {
|
|
lat, _ := strconv.ParseFloat(c.Query("lat"), 64)
|
|
lng, _ := strconv.ParseFloat(c.Query("lng"), 64)
|
|
|
|
maxPackages, maxDestinations := CxBookingLimits(appLocationForPoint(lat, lng))
|
|
return utils.CxOK(c, fiber.Map{
|
|
"maxPackages": maxPackages,
|
|
"maxDestinations": maxDestinations,
|
|
})
|
|
}
|
|
|
|
// CxBookingLimits resolves the caps for a city, falling back to the global row
|
|
// and then to the built-in defaults. Never returns 0 for either: a zero cap
|
|
// rejects every booking, and a configuration mistake must not be able to take
|
|
// the product offline.
|
|
func CxBookingLimits(appLocationID *int) (maxPackages, maxDestinations int) {
|
|
maxPackages, maxDestinations = cxDefaultMaxPackages, cxDefaultMaxDestinations
|
|
|
|
var limit models.CustomerBookingLimit
|
|
found := false
|
|
if appLocationID != nil {
|
|
if err := db.DB.Where("applocationid = ?", *appLocationID).First(&limit).Error; err == nil {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
if err := db.DB.Where("applocationid IS NULL").First(&limit).Error; err == nil {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
return
|
|
}
|
|
if limit.Maxpackages > 0 {
|
|
maxPackages = limit.Maxpackages
|
|
}
|
|
if limit.Maxdestinations > 0 {
|
|
maxDestinations = limit.Maxdestinations
|
|
}
|
|
return
|
|
}
|