delivery slot api creation
This commit is contained in:
271
services/deliverySlotService.go
Normal file
271
services/deliverySlotService.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user