272 lines
9.0 KiB
Go
272 lines
9.0 KiB
Go
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()
|
||
}
|