delivery slot api creation
This commit is contained in:
@@ -65,6 +65,15 @@ ENV NUTRITION_BASE=https://mcp.nearle.ai.in/api
|
||||
# config.Load applies: production insists on a signing secret and never falls
|
||||
# back to localhost values. Every other real value comes from the platform's
|
||||
# environment settings, exactly as before.
|
||||
# The clock every business rule is decided on.
|
||||
#
|
||||
# tzdata was already installed and nothing set TZ, so the container ran UTC.
|
||||
# That was invisible while time.Now() only ever stamped records — nothing
|
||||
# compared a stored time of day against the current one. Delivery windows are
|
||||
# the first rule that does: a shop setting morning as 08:00-10:00 would have had
|
||||
# it close at 10:00 UTC, which is 15:30 where the shop is standing.
|
||||
ENV TZ=Asia/Kolkata
|
||||
|
||||
ENV APP_ENV=production
|
||||
|
||||
# Must match APP_PORT in the platform's environment (1009 in production).
|
||||
|
||||
146
controllers/deliverySlotController.go
Normal file
146
controllers/deliverySlotController.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"nearle/models"
|
||||
"nearle/services"
|
||||
)
|
||||
|
||||
type DeliverySlotController struct {
|
||||
service services.DeliverySlotService
|
||||
}
|
||||
|
||||
func NewDeliverySlotController(service services.DeliverySlotService) *DeliverySlotController {
|
||||
return &DeliverySlotController{service: service}
|
||||
}
|
||||
|
||||
/*
|
||||
GET /v1/web/deliveryslots?tenantid&locationid
|
||||
|
||||
Everything a branch has configured, active or not, for the console's editor.
|
||||
A branch that has set nothing returns an empty list — see the note on Available
|
||||
about why that is never an error.
|
||||
*/
|
||||
func (ctl *DeliverySlotController) ListDeliverySlots(c *fiber.Ctx) error {
|
||||
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
|
||||
locationID, _ := strconv.Atoi(c.Query("locationid"))
|
||||
|
||||
if tenantID <= 0 {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "tenantid is required",
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
slots, err := ctl.service.ListForBranch(tenantID, locationID)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"code": http.StatusInternalServerError,
|
||||
"message": err.Error(),
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"code": http.StatusOK,
|
||||
"message": "Success",
|
||||
"status": true,
|
||||
"details": slots,
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
PUT /v1/web/deliveryslots
|
||||
|
||||
The branch's windows, all three together rather than one at a time: they are
|
||||
edited as a set on one screen, and sending them together is what lets the
|
||||
service reject the whole edit when one row is wrong instead of applying part of
|
||||
it.
|
||||
|
||||
A business objection — an unreadable time, a window ending before it starts,
|
||||
the same key twice — is 409 and not 500. It is an answer about the request, and
|
||||
the message is written to be shown to the person who typed it.
|
||||
*/
|
||||
func (ctl *DeliverySlotController) SaveDeliverySlots(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Tenantid int `json:"tenantid"`
|
||||
Locationid int `json:"locationid"`
|
||||
Slots []models.DeliverySlots `json:"slots"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "Invalid request body",
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
if req.Tenantid <= 0 {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "tenantid is required",
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
if err := ctl.service.Save(req.Tenantid, req.Locationid, req.Slots); err != nil {
|
||||
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||
"code": http.StatusConflict,
|
||||
"message": err.Error(),
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"code": http.StatusOK,
|
||||
"message": "Successfully Updated",
|
||||
"status": true,
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
GET /v1/mob/deliveryslots/available?tenantid&locationid
|
||||
|
||||
What the shopper may pick, already filtered and dated. The app renders this list
|
||||
and does no time arithmetic of its own — see the service for why one clock has
|
||||
to be authoritative.
|
||||
|
||||
An empty list is 200 with `details: []`, NOT an error. It means this branch has
|
||||
set no windows, which is the state every shop is in today, and the app is
|
||||
required to fall back to ordering without one. Returning 404 here would turn an
|
||||
ordinary shop into a broken one.
|
||||
*/
|
||||
func (ctl *DeliverySlotController) AvailableDeliverySlots(c *fiber.Ctx) error {
|
||||
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
|
||||
locationID, _ := strconv.Atoi(c.Query("locationid"))
|
||||
|
||||
if tenantID <= 0 {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "tenantid is required",
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
slots, err := ctl.service.Available(tenantID, locationID)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"code": http.StatusInternalServerError,
|
||||
"message": err.Error(),
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"code": http.StatusOK,
|
||||
"message": "Success",
|
||||
"status": true,
|
||||
"details": slots,
|
||||
})
|
||||
}
|
||||
@@ -16,10 +16,20 @@ import (
|
||||
|
||||
type OrderController struct {
|
||||
orderService services.OrderService
|
||||
// Asked whether a chosen delivery window is still open. Held here rather
|
||||
// than reimplemented, so the app's list and this check can never disagree
|
||||
// about where a window ends.
|
||||
deliverySlotService services.DeliverySlotService
|
||||
}
|
||||
|
||||
func NewOrderController(orderService services.OrderService) *OrderController {
|
||||
return &OrderController{orderService: orderService}
|
||||
func NewOrderController(
|
||||
orderService services.OrderService,
|
||||
deliverySlotService services.DeliverySlotService,
|
||||
) *OrderController {
|
||||
return &OrderController{
|
||||
orderService: orderService,
|
||||
deliverySlotService: deliverySlotService,
|
||||
}
|
||||
}
|
||||
|
||||
func (ctl *OrderController) GetOrders(c *fiber.Ctx) error {
|
||||
@@ -126,7 +136,6 @@ func (ctl *OrderController) GetOrders(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
func (ctl *OrderController) GetOrderSummary(c *fiber.Ctx) error {
|
||||
tid, _ := strconv.Atoi(c.Query("tenantid"))
|
||||
pid, _ := strconv.Atoi(c.Query("partnerid"))
|
||||
@@ -160,7 +169,6 @@ func (ctl *OrderController) GetOrderSummary(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
func (ctl *OrderController) GetlocationOrderSummary(c *fiber.Ctx) error {
|
||||
tenantIDStr := c.Query("tenantid")
|
||||
tenantID, _ := strconv.Atoi(tenantIDStr)
|
||||
@@ -350,6 +358,29 @@ func (ctl *OrderController) CreateOrderv3(c *fiber.Ctx) error {
|
||||
data.Deliverytime = time.Now().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// The chosen delivery window, re-decided here.
|
||||
//
|
||||
// The app sends back what /v1/mob/deliveryslots/available handed it, but
|
||||
// that group carries NO SESSION — anything arriving is a claim, not a fact.
|
||||
// The common case is innocent and still has to be caught: a shopper leaves
|
||||
// the checkout screen open while the window closes, then taps pay.
|
||||
//
|
||||
// 409 rather than 400: the request was well formed and was true when it was
|
||||
// built. The message is written to be shown to the shopper as-is.
|
||||
//
|
||||
// An order naming NO window passes straight through. That is every order
|
||||
// placed before this shipped and every order from a branch that has set no
|
||||
// windows, and it must stay ordinary.
|
||||
if err := ctl.deliverySlotService.ValidateForOrder(
|
||||
data.Tenantid, data.Locationid, data.Deliveryslotid, data.Deliveryslotdate,
|
||||
); err != nil {
|
||||
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||
"code": http.StatusConflict,
|
||||
"message": err.Error(),
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
// An order that does not state its config is an APP order, because that is
|
||||
// the only kind this endpoint takes.
|
||||
//
|
||||
|
||||
@@ -28,6 +28,7 @@ type Facade struct {
|
||||
LiveController *controllers.LiveController
|
||||
CatalogueUploadController *controllers.CatalogueUploadController
|
||||
ScanController *controllers.ScanController
|
||||
DeliverySlotController *controllers.DeliverySlotController
|
||||
AssistantController *controllers.AssistantController
|
||||
HealthController *controllers.HealthController
|
||||
MCPController *controllers.MCPController
|
||||
@@ -92,10 +93,19 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat
|
||||
productRepo, catalogueService, services.NewNutritionService(nutritionBase))
|
||||
productController := controllers.NewProductController(productService)
|
||||
|
||||
// When each branch delivers.
|
||||
//
|
||||
// BEFORE the order controller, which takes it: order creation re-checks a
|
||||
// chosen window against the same rule the app was shown, so the two cannot
|
||||
// drift. No dependency the other way — this service knows nothing of orders.
|
||||
deliverySlotRepo := repositories.NewDeliverySlotRepository(db)
|
||||
deliverySlotService := services.NewDeliverySlotService(deliverySlotRepo)
|
||||
deliverySlotController := controllers.NewDeliverySlotController(deliverySlotService)
|
||||
|
||||
// Order Module
|
||||
orderRepo := repositories.NewOrderRepository(db)
|
||||
orderService := services.NewOrderService(orderRepo)
|
||||
orderController := controllers.NewOrderController(orderService)
|
||||
orderController := controllers.NewOrderController(orderService, deliverySlotService)
|
||||
|
||||
// Deliveries Module
|
||||
deliveriesRepo := repositories.NewDeliveriesRepository(db)
|
||||
@@ -241,6 +251,7 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat
|
||||
LiveController: liveController,
|
||||
CatalogueUploadController: catalogueUploadController,
|
||||
ScanController: scanController,
|
||||
DeliverySlotController: deliverySlotController,
|
||||
AssistantController: assistantController,
|
||||
HealthController: healthController,
|
||||
MCPController: mcpController,
|
||||
|
||||
55
main.go
55
main.go
@@ -196,6 +196,61 @@ func main() {
|
||||
log.Println("⚠️ could not add products.showhealthscore, every product will keep showing its health score:", err)
|
||||
}
|
||||
|
||||
// When a shop delivers, and which window an order chose.
|
||||
//
|
||||
// Three named windows a day per BRANCH — see models/deliveryslot.go for why
|
||||
// the scope is the branch and not the company.
|
||||
//
|
||||
// ── A branch with no rows here still trades ─────────────────────────────
|
||||
//
|
||||
// Every tenant on the platform the day this ships has no slots, and all of
|
||||
// them must keep taking orders exactly as before. No backfill, no defaults
|
||||
// written here: absence means "order without a slot", and the app is
|
||||
// required to treat an empty list as ordinary rather than as a closed shop.
|
||||
// Seeding every existing branch with invented timings would have each one
|
||||
// promising hours nobody agreed to.
|
||||
if err := db.DB.Exec(`CREATE TABLE IF NOT EXISTS deliveryslots (
|
||||
slotid SERIAL PRIMARY KEY,
|
||||
tenantid INTEGER NOT NULL,
|
||||
locationid INTEGER NOT NULL DEFAULT 0,
|
||||
slotkey TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
starttime TEXT NOT NULL,
|
||||
endtime TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`).Error; err != nil {
|
||||
log.Println("⚠️ could not create deliveryslots, delivery windows will be unavailable:", err)
|
||||
}
|
||||
|
||||
// One row per key per branch. A shop has ONE morning, and a duplicate would
|
||||
// show the shopper the same window twice with different hours — so the
|
||||
// upsert that writes these leans on this constraint rather than on a
|
||||
// read-then-write that two requests could interleave.
|
||||
if err := db.DB.Exec(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS deliveryslots_branch_key
|
||||
ON deliveryslots (tenantid, locationid, slotkey)`).Error; err != nil {
|
||||
log.Println("⚠️ could not add the deliveryslots uniqueness index, a branch may end up with duplicate windows:", err)
|
||||
}
|
||||
|
||||
// The window an order chose, and the day it falls on.
|
||||
//
|
||||
// Both nullable, and both stay empty for every order placed without a slot —
|
||||
// which is every order today and every order from a branch that never sets
|
||||
// timings. Nothing downstream may require them.
|
||||
//
|
||||
// The DATE is not redundant. "evening" cannot say tonight or tomorrow night,
|
||||
// and an order placed after the last window closes is for the next day.
|
||||
if err := db.DB.Exec(
|
||||
`ALTER TABLE orders ADD COLUMN IF NOT EXISTS deliveryslotid INTEGER`).Error; err != nil {
|
||||
log.Println("⚠️ could not add orders.deliveryslotid, orders will not record a delivery window:", err)
|
||||
}
|
||||
if err := db.DB.Exec(
|
||||
`ALTER TABLE orders ADD COLUMN IF NOT EXISTS deliveryslotdate DATE`).Error; err != nil {
|
||||
log.Println("⚠️ could not add orders.deliveryslotdate, orders will not record which day their window falls on:", err)
|
||||
}
|
||||
|
||||
// When a product became visible to a store, and the only thing that decides
|
||||
// whether it is.
|
||||
//
|
||||
|
||||
111
models/deliveryslot.go
Normal file
111
models/deliveryslot.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
/*
|
||||
When a shop delivers.
|
||||
|
||||
A branch offers at most three windows a day — morning, afternoon, evening — and
|
||||
the shopper picks one at checkout. The window is a PREFERENCE, not a promise:
|
||||
every order is accepted, there is no capacity limit, and a slot never fills up.
|
||||
It tells the shop when to group a drop, and it tells the shopper roughly when to
|
||||
expect one.
|
||||
|
||||
── Per branch, not per tenant ──────────────────────────────────────────────
|
||||
|
||||
Timings belong to a shop floor, not a company. A tenant with an outlet in a
|
||||
market and another in an office park will run different hours, and discovering
|
||||
that after the fact would mean migrating live rows. `locationid` is on the table
|
||||
from the start for that reason, and onboarding simply fills it with the primary
|
||||
branch it just created.
|
||||
|
||||
── A branch with no slots is not broken ────────────────────────────────────
|
||||
|
||||
Every tenant trading today has no slots at all, and must keep taking orders.
|
||||
Absence means "order without a slot", exactly as before — never "this shop is
|
||||
closed". The whole rollout rests on that, so nothing here may treat an empty
|
||||
list as an error.
|
||||
*/
|
||||
type DeliverySlots struct {
|
||||
Slotid int `json:"deliveryslotid" gorm:"primaryKey;autoIncrement;column:slotid"`
|
||||
Tenantid int `json:"tenantid" gorm:"column:tenantid;index"`
|
||||
Locationid int `json:"locationid" gorm:"column:locationid;index"`
|
||||
|
||||
// Which of the three this is. Fixed rather than free-form: the app shows
|
||||
// them in a known order and may want an icon per slot, and a shop inventing
|
||||
// a fourth would have nowhere to appear.
|
||||
Slotkey string `json:"slotkey" gorm:"column:slotkey"`
|
||||
|
||||
// What the shopper reads. Separate from `slotkey` so a shop can say
|
||||
// "Before work" without breaking the app's ordering.
|
||||
Name string `json:"name" gorm:"column:name"`
|
||||
|
||||
// "HH:MM", 24-hour, in the shop's local time.
|
||||
//
|
||||
// Stored as text, not as a timestamp, because this is a time of DAY that
|
||||
// recurs — it has no date until an order attaches one. A timestamp column
|
||||
// would invite a timezone conversion on every read, which is the one thing
|
||||
// this must not do: the shopkeeper typed 08:00 meaning eight in the morning
|
||||
// where they are standing.
|
||||
Starttime string `json:"starttime" gorm:"column:starttime"`
|
||||
|
||||
// Also the CUT-OFF. There is deliberately no separate cutoff column: a slot
|
||||
// accepts orders right up to the moment it ends, and then stops being
|
||||
// offered. Morning 08:00–10:00 takes an order at 09:59 and not at 10:01.
|
||||
Endtime string `json:"endtime" gorm:"column:endtime"`
|
||||
|
||||
// "active" or "inactive". A shop that stops doing evenings turns the slot
|
||||
// off rather than deleting it, so orders already placed against it still
|
||||
// resolve to something with a name.
|
||||
Status string `json:"status" gorm:"column:status"`
|
||||
|
||||
Created time.Time `json:"created" gorm:"column:created;autoCreateTime"`
|
||||
Updated time.Time `json:"updated" gorm:"column:updated;autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (DeliverySlots) TableName() string {
|
||||
return "deliveryslots"
|
||||
}
|
||||
|
||||
// The three keys, in the order a shopper reads them.
|
||||
const (
|
||||
SlotMorning = "morning"
|
||||
SlotAfternoon = "afternoon"
|
||||
SlotEvening = "evening"
|
||||
)
|
||||
|
||||
// SlotKeys is the whole set, in display order. Used to validate input and to
|
||||
// seed a new branch.
|
||||
var SlotKeys = []string{SlotMorning, SlotAfternoon, SlotEvening}
|
||||
|
||||
// IsSlotKey reports whether a key is one of the three.
|
||||
func IsSlotKey(key string) bool {
|
||||
return slices.Contains(SlotKeys, key)
|
||||
}
|
||||
|
||||
/*
|
||||
A slot offered to a shopper, with the day it falls on.
|
||||
|
||||
`DeliverySlots` describes a window that recurs; this is one concrete occurrence
|
||||
of it. The app needs the date because "evening" alone cannot distinguish tonight
|
||||
from tomorrow night, and once today's last window closes the next thing on offer
|
||||
is tomorrow morning.
|
||||
|
||||
The app does NO time arithmetic. It renders what this list contains, and the
|
||||
list already excludes anything that has closed.
|
||||
*/
|
||||
type AvailableDeliverySlot struct {
|
||||
Slotid int `json:"deliveryslotid"`
|
||||
Slotkey string `json:"slotkey"`
|
||||
Name string `json:"name"`
|
||||
Starttime string `json:"starttime"`
|
||||
Endtime string `json:"endtime"`
|
||||
// "YYYY-MM-DD", the day this window falls on.
|
||||
Slotdate string `json:"slotdate"`
|
||||
// True when `Slotdate` is not today. Saves the app comparing dates to
|
||||
// decide whether to write "Tomorrow" beside the name.
|
||||
IsTomorrow bool `json:"istomorrow"`
|
||||
}
|
||||
@@ -246,6 +246,17 @@ type Orders struct {
|
||||
Orderdate string `json:"orderdate,omitempty"`
|
||||
Deliverytime string `json:"deliverytime"`
|
||||
Deliverytype string `json:"deliverytype"`
|
||||
// The window the shopper chose, and the day it falls on.
|
||||
//
|
||||
// Both stay zero for every order placed without one — which is every order
|
||||
// before this shipped, and every order from a branch that has set no
|
||||
// windows. Nothing downstream may require them.
|
||||
//
|
||||
// Distinct from `Deliverytime` above, which is a TIMESTAMP of what happened
|
||||
// and is defaulted to now() a few lines into CreateOrderv3. These two say
|
||||
// what was asked for; that one says what occurred.
|
||||
Deliveryslotid int `json:"deliveryslotid" gorm:"column:deliveryslotid"`
|
||||
Deliveryslotdate string `json:"deliveryslotdate" gorm:"column:deliveryslotdate"`
|
||||
Orderstatus string `json:"orderstatus"`
|
||||
Pending string `json:"pending"`
|
||||
Processing string `json:"processing"`
|
||||
|
||||
97
repositories/deliverySlotRepository.go
Normal file
97
repositories/deliverySlotRepository.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"nearle/models"
|
||||
)
|
||||
|
||||
type DeliverySlotRepository interface {
|
||||
ListForBranch(tenantID, locationID int) ([]models.DeliverySlots, error)
|
||||
FindForBranch(tenantID, locationID, slotID int) (*models.DeliverySlots, error)
|
||||
Save(slots []models.DeliverySlots) error
|
||||
}
|
||||
|
||||
type deliverySlotRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewDeliverySlotRepository(db *gorm.DB) DeliverySlotRepository {
|
||||
return &deliverySlotRepository{db: db}
|
||||
}
|
||||
|
||||
// Ordered by start time so every caller — the console's editor and the app's
|
||||
// list alike — sees morning before evening without sorting it again.
|
||||
func (r *deliverySlotRepository) ListForBranch(tenantID, locationID int) ([]models.DeliverySlots, error) {
|
||||
slots := make([]models.DeliverySlots, 0, len(models.SlotKeys))
|
||||
|
||||
err := r.db.Table("deliveryslots").
|
||||
Where("tenantid = ? AND locationid = ?", tenantID, locationID).
|
||||
Order("starttime ASC").
|
||||
Find(&slots).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return slots, nil
|
||||
}
|
||||
|
||||
/*
|
||||
One window, scoped to the branch that claims it.
|
||||
|
||||
The tenant and location are in the WHERE and not checked afterwards: a slot id
|
||||
arrives from the app, which carries no session, so it is a claim about which
|
||||
shop it belongs to. Looking it up by id alone and trusting the row would let one
|
||||
shop's checkout name another shop's window.
|
||||
*/
|
||||
func (r *deliverySlotRepository) FindForBranch(tenantID, locationID, slotID int) (*models.DeliverySlots, error) {
|
||||
var slot models.DeliverySlots
|
||||
|
||||
err := r.db.Table("deliveryslots").
|
||||
Where("slotid = ? AND tenantid = ? AND locationid = ?", slotID, tenantID, locationID).
|
||||
First(&slot).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// Not an error: "this shop does not offer that" is an answer, and the
|
||||
// service turns it into something a shopper can read.
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &slot, nil
|
||||
}
|
||||
|
||||
/*
|
||||
Write a branch's windows, all of them or none.
|
||||
|
||||
── Upsert, not delete-then-insert ──────────────────────────────────────────
|
||||
|
||||
Slot ids are referenced by `orders.deliveryslotid`. Replacing the rows would
|
||||
renumber them, and every order already placed would point at a window that no
|
||||
longer means what it did — or at nothing. The unique index on
|
||||
(tenantid, locationid, slotkey) is what makes the conflict target work, so a
|
||||
branch keeps one morning however many times it is edited.
|
||||
|
||||
── One transaction ─────────────────────────────────────────────────────────
|
||||
|
||||
A shop editing all three and getting two is worse than getting none: the two
|
||||
that took are live and serving shoppers, and nothing on screen says which.
|
||||
*/
|
||||
func (r *deliverySlotRepository) Save(slots []models.DeliverySlots) error {
|
||||
if len(slots) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
return tx.Table("deliveryslots").
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{
|
||||
{Name: "tenantid"}, {Name: "locationid"}, {Name: "slotkey"},
|
||||
},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"name", "starttime", "endtime", "status", "updated"}),
|
||||
}).
|
||||
Create(&slots).Error
|
||||
})
|
||||
}
|
||||
33
routes/deliveryslotroutes.go
Normal file
33
routes/deliveryslotroutes.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"nearle/facade"
|
||||
)
|
||||
|
||||
/*
|
||||
Delivery windows.
|
||||
|
||||
── The web half is guarded, the app half is read-only ──────────────────────
|
||||
|
||||
Setting a branch's hours is a tenant-wide decision with money behind it, so it
|
||||
sits on /v1/web where WebAuth checks the session and the tenant scope. Reading
|
||||
what is on offer is what a shopper's app does before it has any identity at all,
|
||||
so that one endpoint — and only that one — lives on the unauthenticated /v1/mob
|
||||
group.
|
||||
|
||||
There is deliberately NO write route on the mob group. The /v1/mob/* group has
|
||||
no session at all, and a shop's trading hours are not something an anonymous
|
||||
caller gets to set.
|
||||
*/
|
||||
func RegisterDeliverySlotRoutes(api fiber.Router, f *facade.Facade) {
|
||||
// ── Console: read and edit a branch's windows ───────────────────────────
|
||||
web := api.Group("/v1/web/deliveryslots")
|
||||
web.Get("/", f.DeliverySlotController.ListDeliverySlots)
|
||||
web.Put("/", f.DeliverySlotController.SaveDeliverySlots)
|
||||
|
||||
// ── App: what a shopper may pick, already filtered ──────────────────────
|
||||
mob := api.Group("/v1/mob/deliveryslots")
|
||||
mob.Get("/available", f.DeliverySlotController.AvailableDeliverySlots)
|
||||
}
|
||||
@@ -40,6 +40,7 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) {
|
||||
RegisterUploadRoutes(api, f)
|
||||
RegisterScanRoutes(api, f)
|
||||
RegisterAssistantRoutes(api, f)
|
||||
RegisterDeliverySlotRoutes(api, f)
|
||||
|
||||
// What is running here.
|
||||
//
|
||||
|
||||
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()
|
||||
}
|
||||
257
services/deliverySlotService_test.go
Normal file
257
services/deliverySlotService_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user