Files
backend_fiesta/services/deliverySlotService.go
2026-10-06 17:35:51 +05:30

272 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}