package services import ( "fmt" "sort" "strings" "time" "nearle/models" "nearle/repositories" ) /* Delivery windows, and the one rule that decides whether a shopper may pick one. ── The rule ──────────────────────────────────────────────────────────────── A slot is on offer while it is active and has not ended. Morning 08:00–10:00 takes an order at 09:59 and refuses one at 10:01. There is no separate cut-off to configure: the end of the window IS the cut-off, which is what the shop already told us when they typed it. Once the last window of the day has closed, the next thing on offer is tomorrow morning — not nothing. A shopper at 9pm is ordering for tomorrow, and the alternative (offering no slot at all after dark) would quietly drop the feature for every evening customer. ── Why the rule lives here and nowhere else ──────────────────────────────── Two callers need it: the app, asking what to show, and order creation, checking what came back. If those disagree by so much as a boundary, the app offers a window the server then rejects, and the shopper is told their basket is invalid for reasons nothing on screen explains. One function, both callers. */ type DeliverySlotService interface { // What a branch has configured, open or closed, for the console to edit. ListForBranch(tenantID, locationID int) ([]models.DeliverySlots, error) // What a shopper may pick right now, already filtered and dated. Available(tenantID, locationID int) ([]models.AvailableDeliverySlot, error) // Create or update the three windows for a branch. Save(tenantID, locationID int, slots []models.DeliverySlots) error // Confirm an order may use this slot on this date. Returns a message fit to // show a shopper when it may not. ValidateForOrder(tenantID, locationID, slotID int, slotDate string) error } type deliverySlotService struct { repo repositories.DeliverySlotRepository // Injected so tests can sit at a chosen moment rather than waiting for one. now func() time.Time } func NewDeliverySlotService(repo repositories.DeliverySlotRepository) DeliverySlotService { return &deliverySlotService{repo: repo, now: time.Now} } const slotDateLayout = "2006-01-02" const slotTimeLayout = "15:04" func (s *deliverySlotService) ListForBranch(tenantID, locationID int) ([]models.DeliverySlots, error) { if tenantID <= 0 { return nil, fmt.Errorf("tenantid is required") } return s.repo.ListForBranch(tenantID, locationID) } /* The windows a shopper may pick, soonest first. Today's remaining windows come first, then tomorrow's — so a shopper at 9am sees morning, afternoon and evening, one at 11am sees afternoon and evening, and one at 9pm sees tomorrow's three. Returns an EMPTY list, never an error, when a branch has configured nothing. That is the ordinary state for every shop trading today and means "order without a window", not "this shop is shut". */ func (s *deliverySlotService) Available(tenantID, locationID int) ([]models.AvailableDeliverySlot, error) { if tenantID <= 0 { return nil, fmt.Errorf("tenantid is required") } configured, err := s.repo.ListForBranch(tenantID, locationID) if err != nil { return nil, err } now := s.now() today := now.Format(slotDateLayout) tomorrow := now.AddDate(0, 0, 1).Format(slotDateLayout) // Non-nil so the JSON is `[]` rather than `null`. An app checking // `length === 0` and one checking for null should not get different answers. available := make([]models.AvailableDeliverySlot, 0, len(configured)) for _, slot := range configured { if !strings.EqualFold(slot.Status, "active") { continue } end, ok := parseSlotTime(slot.Endtime) if !ok { // A window whose hours cannot be read is skipped rather than // guessed at. Offering a slot we cannot tell the end of would mean // accepting orders against it forever. continue } // Today, but only while it is still running. if minutesSinceMidnight(now) < end { available = append(available, toAvailable(slot, today, false)) } // And always tomorrow, since by then it has come round again. available = append(available, toAvailable(slot, tomorrow, true)) } sort.SliceStable(available, func(i, j int) bool { if available[i].Slotdate != available[j].Slotdate { return available[i].Slotdate < available[j].Slotdate } return available[i].Starttime < available[j].Starttime }) return available, nil } func toAvailable(slot models.DeliverySlots, date string, isTomorrow bool) models.AvailableDeliverySlot { name := strings.TrimSpace(slot.Name) if name == "" { // A shop that never renamed the window still needs something readable // on the app's button. name = strings.ToUpper(slot.Slotkey[:1]) + slot.Slotkey[1:] } return models.AvailableDeliverySlot{ Slotid: slot.Slotid, Slotkey: slot.Slotkey, Name: name, Starttime: slot.Starttime, Endtime: slot.Endtime, Slotdate: date, IsTomorrow: isTomorrow, } } /* Write a branch's windows. Validates every row before writing any of them: a half-applied set would leave a shop with a morning it just edited and an evening it did not, with nothing on screen saying which took. */ func (s *deliverySlotService) Save(tenantID, locationID int, slots []models.DeliverySlots) error { if tenantID <= 0 { return fmt.Errorf("tenantid is required") } if len(slots) == 0 { return fmt.Errorf("at least one delivery window is required") } seen := make(map[string]bool, len(slots)) prepared := make([]models.DeliverySlots, 0, len(slots)) for _, slot := range slots { key := strings.ToLower(strings.TrimSpace(slot.Slotkey)) if !models.IsSlotKey(key) { return fmt.Errorf("%q is not a delivery window — expected morning, afternoon or evening", slot.Slotkey) } if seen[key] { return fmt.Errorf("%s was given twice; a branch has one of each", key) } seen[key] = true start, startOK := parseSlotTime(slot.Starttime) end, endOK := parseSlotTime(slot.Endtime) if !startOK || !endOK { return fmt.Errorf("%s needs a start and end time as HH:MM", key) } if end <= start { // Not a pedantic check: an end before its start never passes the // "still running" test, so the window would silently never appear. return fmt.Errorf("%s ends at or before it starts (%s–%s)", key, slot.Starttime, slot.Endtime) } status := strings.ToLower(strings.TrimSpace(slot.Status)) if status != "inactive" { status = "active" } prepared = append(prepared, models.DeliverySlots{ Tenantid: tenantID, Locationid: locationID, Slotkey: key, Name: strings.TrimSpace(slot.Name), Starttime: slot.Starttime, Endtime: slot.Endtime, Status: status, }) } return s.repo.Save(prepared) } /* May this order use this window? Called on every order that names one. The app sends back what `Available` gave it, but the app is not the authority — `/v1/mob/*` carries no session, so anything arriving here is a claim. Re-deciding with the same rule is the only thing standing between a shopper and a window that closed while they were checking out. */ func (s *deliverySlotService) ValidateForOrder(tenantID, locationID, slotID int, slotDate string) error { if slotID <= 0 { return nil // No window chosen. Ordinary — see models/deliveryslot.go. } if tenantID <= 0 { return fmt.Errorf("tenantid is required") } slot, err := s.repo.FindForBranch(tenantID, locationID, slotID) if err != nil { return err } if slot == nil { return fmt.Errorf("that delivery window is not one this shop offers") } if !strings.EqualFold(slot.Status, "active") { return fmt.Errorf("the %s window is not currently available", slot.Slotkey) } date, err := time.Parse(slotDateLayout, strings.TrimSpace(slotDate)) if err != nil { return fmt.Errorf("a delivery date is required with a delivery window") } now := s.now() today := now.Truncate(24 * time.Hour) chosen := date.Truncate(24 * time.Hour) if chosen.Before(today) { return fmt.Errorf("that delivery window has already passed") } if chosen.Equal(today) { end, ok := parseSlotTime(slot.Endtime) if !ok { return fmt.Errorf("the %s window has no readable end time", slot.Slotkey) } if minutesSinceMidnight(now) >= end { // The exact case this guard exists for: a shopper who sat on the // checkout screen while the window closed. return fmt.Errorf("the %s window has closed for today — please choose another", slot.Slotkey) } } return nil } // "HH:MM" as minutes past midnight. The second return is false for anything // unreadable, which every caller treats as "do not offer", never as zero — // zero would mean midnight and make a broken window look like one that has // already closed. func parseSlotTime(value string) (int, bool) { parsed, err := time.Parse(slotTimeLayout, strings.TrimSpace(value)) if err != nil { return 0, false } return parsed.Hour()*60 + parsed.Minute(), true } func minutesSinceMidnight(at time.Time) int { return at.Hour()*60 + at.Minute() }