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()
}

View File

@@ -0,0 +1,257 @@
package services
import (
"strings"
"testing"
"time"
"nearle/models"
)
// A stand-in for the table, so these tests are about the RULE and not about
// GORM. Every test below fixes "now" explicitly, because a rule about clock
// time that is tested at whatever o'clock CI happens to run is not tested.
type fakeSlotRepo struct {
slots []models.DeliverySlots
}
func (f *fakeSlotRepo) ListForBranch(tenantID, locationID int) ([]models.DeliverySlots, error) {
return f.slots, nil
}
func (f *fakeSlotRepo) FindForBranch(tenantID, locationID, slotID int) (*models.DeliverySlots, error) {
for i := range f.slots {
if f.slots[i].Slotid == slotID {
return &f.slots[i], nil
}
}
return nil, nil
}
func (f *fakeSlotRepo) Save(slots []models.DeliverySlots) error {
f.slots = slots
return nil
}
func threeSlots() []models.DeliverySlots {
return []models.DeliverySlots{
{Slotid: 1, Slotkey: "morning", Name: "Morning", Starttime: "08:00", Endtime: "10:00", Status: "active"},
{Slotid: 2, Slotkey: "afternoon", Name: "Afternoon", Starttime: "12:00", Endtime: "15:00", Status: "active"},
{Slotid: 3, Slotkey: "evening", Name: "Evening", Starttime: "17:00", Endtime: "20:00", Status: "active"},
}
}
func serviceAt(clock string, slots []models.DeliverySlots) *deliverySlotService {
at, err := time.Parse("2006-01-02 15:04", clock)
if err != nil {
panic(err)
}
return &deliverySlotService{
repo: &fakeSlotRepo{slots: slots},
now: func() time.Time { return at },
}
}
// Today's windows that have not ended, soonest first.
func todayKeys(t *testing.T, svc *deliverySlotService, date string) []string {
t.Helper()
available, err := svc.Available(1, 1)
if err != nil {
t.Fatalf("Available: %v", err)
}
keys := []string{}
for _, slot := range available {
if slot.Slotdate == date {
keys = append(keys, slot.Slotkey)
}
}
return keys
}
func TestAvailableDropsWindowsThatHaveEnded(t *testing.T) {
// The rule the shop was given: a window takes orders right up to the moment
// it ends, and then stops being offered.
cases := []struct {
clock string
want string
}{
{"2026-10-06 07:00", "morning,afternoon,evening"}, // before trading
{"2026-10-06 09:59", "morning,afternoon,evening"}, // one minute left
{"2026-10-06 10:01", "afternoon,evening"}, // morning just closed
{"2026-10-06 15:30", "evening"}, // afternoon gone too
{"2026-10-06 20:01", ""}, // day over
}
for _, tc := range cases {
svc := serviceAt(tc.clock, threeSlots())
got := strings.Join(todayKeys(t, svc, "2026-10-06"), ",")
if got != tc.want {
t.Errorf("at %s: today offers %q, want %q", tc.clock, got, tc.want)
}
}
}
func TestAvailableClosesOnTheEndMinuteNotTheStart(t *testing.T) {
// The boundary, stated on its own because it is the one thing a shopkeeper
// would notice being wrong. 10:00 exactly is PAST the end of an 08:00-10:00
// window: the window is over, and an order placed at 10:00 cannot be in it.
svc := serviceAt("2026-10-06 10:00", threeSlots())
for _, key := range todayKeys(t, svc, "2026-10-06") {
if key == "morning" {
t.Error("morning is still on offer at 10:00, its own end time")
}
}
}
func TestAvailableAlwaysOffersTomorrow(t *testing.T) {
// A shopper at 9pm is ordering for tomorrow. Offering nothing would quietly
// drop the feature for every evening customer.
svc := serviceAt("2026-10-06 21:00", threeSlots())
available, err := svc.Available(1, 1)
if err != nil {
t.Fatalf("Available: %v", err)
}
if len(available) != 3 {
t.Fatalf("after the last window closed, got %d slots, want tomorrow's 3", len(available))
}
for _, slot := range available {
if slot.Slotdate != "2026-10-07" {
t.Errorf("%s is dated %s, want tomorrow", slot.Slotkey, slot.Slotdate)
}
if !slot.IsTomorrow {
t.Errorf("%s is tomorrow's but istomorrow is false", slot.Slotkey)
}
}
}
func TestAvailableSkipsInactiveWindows(t *testing.T) {
slots := threeSlots()
slots[1].Status = "inactive" // the shop stopped doing afternoons
svc := serviceAt("2026-10-06 07:00", slots)
for _, key := range todayKeys(t, svc, "2026-10-06") {
if key == "afternoon" {
t.Error("an inactive window is being offered to shoppers")
}
}
}
func TestAvailableIsEmptyAndNotAnErrorWhenNothingIsConfigured(t *testing.T) {
// The state EVERY shop is in the day this ships. An error here would turn
// every trading shop on the platform into a broken one.
svc := serviceAt("2026-10-06 09:00", nil)
available, err := svc.Available(1, 1)
if err != nil {
t.Fatalf("a branch with no windows must not be an error, got %v", err)
}
if available == nil {
t.Error("got nil, want an empty slice — `null` and `[]` read differently in the app")
}
if len(available) != 0 {
t.Errorf("got %d slots from a branch that configured none", len(available))
}
}
func TestValidateForOrderAcceptsNoWindow(t *testing.T) {
// Every order placed before this shipped, and every order from a branch
// that has set no windows.
svc := serviceAt("2026-10-06 09:00", threeSlots())
if err := svc.ValidateForOrder(1, 1, 0, ""); err != nil {
t.Errorf("an order with no window was rejected: %v", err)
}
}
func TestValidateForOrderRejectsAWindowThatClosedWhileCheckingOut(t *testing.T) {
// The case this guard exists for: the shopper opened checkout at 09:58 and
// paid at 10:02.
svc := serviceAt("2026-10-06 10:02", threeSlots())
err := svc.ValidateForOrder(1, 1, 1, "2026-10-06")
if err == nil {
t.Fatal("an order was accepted into a window that had already closed")
}
if !strings.Contains(err.Error(), "morning") {
t.Errorf("the message does not name the window: %q", err)
}
}
func TestValidateForOrderAcceptsTomorrowsWindowAfterTodaysClosed(t *testing.T) {
// Same window, next day. Closing today must not close it forever.
svc := serviceAt("2026-10-06 10:02", threeSlots())
if err := svc.ValidateForOrder(1, 1, 1, "2026-10-07"); err != nil {
t.Errorf("tomorrow's morning was rejected at 10:02 today: %v", err)
}
}
func TestValidateForOrderRejectsAWindowFromAnotherShop(t *testing.T) {
// The repository scopes its lookup by tenant and branch, so a slot id that
// belongs elsewhere comes back as nothing.
svc := serviceAt("2026-10-06 09:00", threeSlots())
if err := svc.ValidateForOrder(1, 1, 99, "2026-10-06"); err == nil {
t.Error("an order named a window this shop does not offer and was accepted")
}
}
func TestValidateForOrderRequiresADateWithAWindow(t *testing.T) {
// "evening" alone cannot say tonight or tomorrow night.
svc := serviceAt("2026-10-06 09:00", threeSlots())
if err := svc.ValidateForOrder(1, 1, 3, ""); err == nil {
t.Error("a window was accepted with no date")
}
}
func TestSaveRejectsAWindowThatEndsBeforeItStarts(t *testing.T) {
// Not pedantry: such a window never passes the "still running" test, so it
// would simply never appear, with nothing saying why.
svc := serviceAt("2026-10-06 09:00", nil)
err := svc.Save(1, 1, []models.DeliverySlots{
{Slotkey: "morning", Starttime: "10:00", Endtime: "08:00", Status: "active"},
})
if err == nil {
t.Error("a window ending before it starts was saved")
}
}
func TestSaveRejectsADuplicateWindow(t *testing.T) {
svc := serviceAt("2026-10-06 09:00", nil)
err := svc.Save(1, 1, []models.DeliverySlots{
{Slotkey: "morning", Starttime: "08:00", Endtime: "10:00", Status: "active"},
{Slotkey: "morning", Starttime: "09:00", Endtime: "11:00", Status: "active"},
})
if err == nil {
t.Error("a branch was given two mornings")
}
}
func TestSaveRejectsAKeyThatIsNotOneOfTheThree(t *testing.T) {
svc := serviceAt("2026-10-06 09:00", nil)
err := svc.Save(1, 1, []models.DeliverySlots{
{Slotkey: "midnight", Starttime: "00:00", Endtime: "02:00", Status: "active"},
})
if err == nil {
t.Error("a fourth window was accepted; the app has nowhere to show it")
}
}
func TestSaveAppliesNothingWhenOneRowIsWrong(t *testing.T) {
// A shop editing three and getting two is worse than getting none: the two
// that took are live, and nothing on screen says which.
repo := &fakeSlotRepo{}
svc := &deliverySlotService{repo: repo, now: time.Now}
_ = svc.Save(1, 1, []models.DeliverySlots{
{Slotkey: "morning", Starttime: "08:00", Endtime: "10:00", Status: "active"},
{Slotkey: "afternoon", Starttime: "15:00", Endtime: "12:00", Status: "active"}, // bad
})
if len(repo.slots) != 0 {
t.Errorf("%d windows were written despite one being invalid", len(repo.slots))
}
}