delivery slot api creation

This commit is contained in:
2026-10-06 17:35:51 +05:30
parent f67cbad79a
commit 03ae7d310b
12 changed files with 1137 additions and 104 deletions

View File

@@ -0,0 +1,271 @@
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()
}