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

@@ -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).

View 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,
})
}

View File

@@ -16,117 +16,126 @@ 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 {
tid, _ := strconv.Atoi(c.Query("tenantid"))
pid, _ := strconv.Atoi(c.Query("partnerid"))
cid, _ := strconv.Atoi(c.Query("customerid"))
mid, _ := strconv.Atoi(c.Query("moduleid"))
aid, _ := strconv.Atoi(c.Query("applocationid"))
uid, _ := strconv.Atoi(c.Query("appuserid"))
lid, _ := strconv.Atoi(c.Query("locationid"))
configid, _ := strconv.Atoi(c.Query("configid"))
tid, _ := strconv.Atoi(c.Query("tenantid"))
pid, _ := strconv.Atoi(c.Query("partnerid"))
cid, _ := strconv.Atoi(c.Query("customerid"))
mid, _ := strconv.Atoi(c.Query("moduleid"))
aid, _ := strconv.Atoi(c.Query("applocationid"))
uid, _ := strconv.Atoi(c.Query("appuserid"))
lid, _ := strconv.Atoi(c.Query("locationid"))
configid, _ := strconv.Atoi(c.Query("configid"))
stat := c.Query("status")
fdate := c.Query("fromdate")
tdate := c.Query("todate")
keyword := c.Query("keyword")
stat := c.Query("status")
fdate := c.Query("fromdate")
tdate := c.Query("todate")
keyword := c.Query("keyword")
pageno, _ := strconv.Atoi(c.Query("pageno"))
pagesize, _ := strconv.Atoi(c.Query("pagesize"))
pageno, _ := strconv.Atoi(c.Query("pageno"))
pagesize, _ := strconv.Atoi(c.Query("pagesize"))
if pageno <= 0 {
pageno = 1
}
if pagesize <= 0 {
pagesize = 10
}
if pageno <= 0 {
pageno = 1
}
if pagesize <= 0 {
pagesize = 10
}
// Build dynamic query struct
query := models.DeliveryQuery{
Tenantid: tid,
Partnerid: pid,
Customerid: cid,
Moduleid: mid,
Applocationid: aid,
Locationid: lid,
UserID: uid,
Appuserid: uid,
Configid: configid,
Fromdate: fdate,
ToDate: tdate,
Status: stat,
Keyword: keyword,
Pageno: pageno,
Pagesize: pagesize,
}
// Build dynamic query struct
query := models.DeliveryQuery{
Tenantid: tid,
Partnerid: pid,
Customerid: cid,
Moduleid: mid,
Applocationid: aid,
Locationid: lid,
UserID: uid,
Appuserid: uid,
Configid: configid,
Fromdate: fdate,
ToDate: tdate,
Status: stat,
Keyword: keyword,
Pageno: pageno,
Pagesize: pagesize,
}
var (
orders []models.OrderInfo
err error
)
var (
orders []models.OrderInfo
err error
)
// --------------------------
// 🔥 DYNAMIC ROUTING LOGIC
// --------------------------
// --------------------------
// 🔥 DYNAMIC ROUTING LOGIC
// --------------------------
if tid != 0 && lid != 0 {
// ⭐ Both tenant & location → special handler
orders, err = ctl.orderService.GetTenantLocationOrders(query)
if tid != 0 && lid != 0 {
// ⭐ Both tenant & location → special handler
orders, err = ctl.orderService.GetTenantLocationOrders(query)
} else if tid != 0 {
// Tenant only
orders, err = ctl.orderService.GetTenantOrders(query)
} else if tid != 0 {
// Tenant only
orders, err = ctl.orderService.GetTenantOrders(query)
} else if pid != 0 {
// Partner
orders, err = ctl.orderService.GetPartnerOrders(stat, fdate, tdate, pid, pageno, pagesize, keyword)
} else if pid != 0 {
// Partner
orders, err = ctl.orderService.GetPartnerOrders(stat, fdate, tdate, pid, pageno, pagesize, keyword)
} else if cid != 0 {
// Customer
orders, err = ctl.orderService.GetCustomerOrders(stat, fdate, tdate, cid, mid, pageno, pagesize, keyword)
} else if cid != 0 {
// Customer
orders, err = ctl.orderService.GetCustomerOrders(stat, fdate, tdate, cid, mid, pageno, pagesize, keyword)
} else if aid != 0 {
// App-location orders
orders, err = ctl.orderService.GetAdminOrders(stat, fdate, tdate, aid, pageno, pagesize, keyword)
} else if aid != 0 {
// App-location orders
orders, err = ctl.orderService.GetAdminOrders(stat, fdate, tdate, aid, pageno, pagesize, keyword)
} else if uid != 0 {
// User orders
orders, err = ctl.orderService.GetUserOrders(stat, fdate, tdate, uid, pageno, pagesize, keyword)
} else if uid != 0 {
// User orders
orders, err = ctl.orderService.GetUserOrders(stat, fdate, tdate, uid, pageno, pagesize, keyword)
} else {
// No scoping id supplied (tenantid/partnerid/customerid/applocationid/appuserid).
// Refuse instead of silently returning every order in the database.
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"status": false,
"code": http.StatusBadRequest,
"message": "At least one of tenantid, partnerid, customerid, applocationid or appuserid is required",
})
}
} else {
// No scoping id supplied (tenantid/partnerid/customerid/applocationid/appuserid).
// Refuse instead of silently returning every order in the database.
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"status": false,
"code": http.StatusBadRequest,
"message": "At least one of tenantid, partnerid, customerid, applocationid or appuserid is required",
})
}
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"status": false,
"code": http.StatusInternalServerError,
"message": err.Error(),
})
}
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"status": false,
"code": http.StatusInternalServerError,
"message": err.Error(),
})
}
return c.JSON(fiber.Map{
"status": true,
"code": http.StatusOK,
"message": "Success",
"details": orders,
})
return c.JSON(fiber.Map{
"status": true,
"code": http.StatusOK,
"message": "Success",
"details": orders,
})
}
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.
//
@@ -592,7 +623,7 @@ func (ctl *OrderController) GetTimeSeries(c *fiber.Ctx) error {
"status": false,
})
}
if granularity == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,

View File

@@ -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
View File

@@ -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
View 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"`
}

View File

@@ -233,19 +233,30 @@ type Ordermonths struct {
}
type Orders struct {
Orderheaderid int `json:"orderheaderid" gorm:"Primary_Key"`
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
Applocationid int `json:"applocationid"`
Moduleid int `json:"moduleid"`
Partnerid int `json:"partnerid"`
Configid int `json:"configid"`
Categoryid int `json:"categoryid"`
Subcategoryid int `json:"subcategoryid"`
Orderid string `json:"orderid"`
Orderdate string `json:"orderdate,omitempty"`
Deliverytime string `json:"deliverytime"`
Deliverytype string `json:"deliverytype"`
Orderheaderid int `json:"orderheaderid" gorm:"Primary_Key"`
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
Applocationid int `json:"applocationid"`
Moduleid int `json:"moduleid"`
Partnerid int `json:"partnerid"`
Configid int `json:"configid"`
Categoryid int `json:"categoryid"`
Subcategoryid int `json:"subcategoryid"`
Orderid string `json:"orderid"`
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"`

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

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

View File

@@ -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.
//

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