status updated
This commit is contained in:
@@ -12,10 +12,17 @@ import (
|
|||||||
|
|
||||||
type DeliverySlotController struct {
|
type DeliverySlotController struct {
|
||||||
service services.DeliverySlotService
|
service services.DeliverySlotService
|
||||||
|
// Asked whether the shop is trading at all. Held here rather than inside
|
||||||
|
// the slot service, which answers one question — is this window still
|
||||||
|
// running — and should not also have to know about shop closures.
|
||||||
|
tenants services.TenantService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDeliverySlotController(service services.DeliverySlotService) *DeliverySlotController {
|
func NewDeliverySlotController(
|
||||||
return &DeliverySlotController{service: service}
|
service services.DeliverySlotService,
|
||||||
|
tenants services.TenantService,
|
||||||
|
) *DeliverySlotController {
|
||||||
|
return &DeliverySlotController{service: service, tenants: tenants}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -128,6 +135,28 @@ func (ctl *DeliverySlotController) AvailableDeliverySlots(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
A shut shop offers nothing.
|
||||||
|
|
||||||
|
Without this, a branch closed for the day still hands the app tomorrow
|
||||||
|
morning — and the shopper picks it, reaches checkout, and is refused by
|
||||||
|
the closed-shop guard in order creation with no warning anything was
|
||||||
|
wrong. The empty list is already the app's "no windows here" path, so
|
||||||
|
this needs nothing new on their side.
|
||||||
|
|
||||||
|
The reason rides along so the app can say WHY rather than just showing
|
||||||
|
nothing, on an endpoint it is already calling.
|
||||||
|
*/
|
||||||
|
if reason := ctl.tenants.StoreClosedReason(tenantID, locationID); reason != "" {
|
||||||
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"message": "Success",
|
||||||
|
"status": true,
|
||||||
|
"details": []models.AvailableDeliverySlot{},
|
||||||
|
"closedreason": reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
slots, err := ctl.service.Available(tenantID, locationID)
|
slots, err := ctl.service.Available(tenantID, locationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
|||||||
@@ -20,15 +20,21 @@ type OrderController struct {
|
|||||||
// than reimplemented, so the app's list and this check can never disagree
|
// than reimplemented, so the app's list and this check can never disagree
|
||||||
// about where a window ends.
|
// about where a window ends.
|
||||||
deliverySlotService services.DeliverySlotService
|
deliverySlotService services.DeliverySlotService
|
||||||
|
// Asked whether the shop is trading. Same reasoning: the customer-facing
|
||||||
|
// store list and this check run the same rule, so a shop shown as open is
|
||||||
|
// never refused here.
|
||||||
|
tenantService services.TenantService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOrderController(
|
func NewOrderController(
|
||||||
orderService services.OrderService,
|
orderService services.OrderService,
|
||||||
deliverySlotService services.DeliverySlotService,
|
deliverySlotService services.DeliverySlotService,
|
||||||
|
tenantService services.TenantService,
|
||||||
) *OrderController {
|
) *OrderController {
|
||||||
return &OrderController{
|
return &OrderController{
|
||||||
orderService: orderService,
|
orderService: orderService,
|
||||||
deliverySlotService: deliverySlotService,
|
deliverySlotService: deliverySlotService,
|
||||||
|
tenantService: tenantService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +364,24 @@ func (ctl *OrderController) CreateOrderv3(c *fiber.Ctx) error {
|
|||||||
data.Deliverytime = time.Now().Format("2006-01-02 15:04:05")
|
data.Deliverytime = time.Now().Format("2006-01-02 15:04:05")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Is the shop even trading?
|
||||||
|
|
||||||
|
Checked HERE and not only in the app, because /v1/mob/* carries no
|
||||||
|
session: anything arriving is a claim. The ordinary case is innocent and
|
||||||
|
still has to be caught — a shopper with the app open when the shopkeeper
|
||||||
|
closes, or a stale screen from this morning.
|
||||||
|
|
||||||
|
409 with the shop's own reason, written to be shown as-is.
|
||||||
|
*/
|
||||||
|
if reason := ctl.tenantService.StoreClosedReason(data.Tenantid, data.Locationid); reason != "" {
|
||||||
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||||
|
"code": http.StatusConflict,
|
||||||
|
"message": reason,
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// The chosen delivery window, re-decided here.
|
// The chosen delivery window, re-decided here.
|
||||||
//
|
//
|
||||||
// The app sends back what /v1/mob/deliveryslots/available handed it, but
|
// The app sends back what /v1/mob/deliveryslots/available handed it, but
|
||||||
|
|||||||
@@ -871,3 +871,53 @@ func (ctl *TenantController) ResendInvite(c *fiber.Ctx) error {
|
|||||||
"code": http.StatusOK, "status": true, "message": "Invitation sent.",
|
"code": http.StatusOK, "status": true, "message": "Invitation sent.",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
PUT /v1/web/tenants/storeopen
|
||||||
|
|
||||||
|
Open or close a branch for the day.
|
||||||
|
|
||||||
|
── Why this is on the guarded group ────────────────────────────────────────
|
||||||
|
|
||||||
|
Closing a shop stops it taking money. On /v1/mob/* — which carries no session
|
||||||
|
at all — anyone who could guess a locationid could shut any shop on the
|
||||||
|
platform. WebAuth checks the tenant scope, so a business can only reach its
|
||||||
|
own branches.
|
||||||
|
|
||||||
|
A business objection — a date in the past, a branch that is not yours — is 409
|
||||||
|
and not 500: the request was well formed, and the message is written to be
|
||||||
|
shown to the person who typed it.
|
||||||
|
*/
|
||||||
|
func (ctl *TenantController) SetStoreOpen(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
|
// A POINTER, so "not sent" and "sent as false" stay different answers.
|
||||||
|
// A plain bool would read a missing field as "close this shop".
|
||||||
|
Isopen *bool `json:"isopen"`
|
||||||
|
Closeduntil string `json:"closeduntil"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Isopen == nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "message": "isopen is required", "status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.tenantService.SetStoreOpen(
|
||||||
|
req.Tenantid, req.Locationid, *req.Isopen, req.Closeduntil,
|
||||||
|
); 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,6 +93,18 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat
|
|||||||
productRepo, catalogueService, services.NewNutritionService(nutritionBase))
|
productRepo, catalogueService, services.NewNutritionService(nutritionBase))
|
||||||
productController := controllers.NewProductController(productService)
|
productController := controllers.NewProductController(productService)
|
||||||
|
|
||||||
|
//Tenant Module
|
||||||
|
//
|
||||||
|
// Onboarding, adding a person and commissioning a branch all create an
|
||||||
|
// account with no password, so all three send an invitation. `tenantRepo` and
|
||||||
|
// `inviteService` are built above, where the reasoning is.
|
||||||
|
//
|
||||||
|
// BEFORE the order controller, which takes it: order creation asks this
|
||||||
|
// service whether the shop is trading before accepting, using the same
|
||||||
|
// rule the customer-facing store list runs. No dependency the other way.
|
||||||
|
tenantService := services.NewTenantService(tenantRepo, inviteService)
|
||||||
|
tenantController := controllers.NewTenantController(tenantService)
|
||||||
|
|
||||||
// When each branch delivers.
|
// When each branch delivers.
|
||||||
//
|
//
|
||||||
// BEFORE the order controller, which takes it: order creation re-checks a
|
// BEFORE the order controller, which takes it: order creation re-checks a
|
||||||
@@ -100,12 +112,12 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat
|
|||||||
// drift. No dependency the other way — this service knows nothing of orders.
|
// drift. No dependency the other way — this service knows nothing of orders.
|
||||||
deliverySlotRepo := repositories.NewDeliverySlotRepository(db)
|
deliverySlotRepo := repositories.NewDeliverySlotRepository(db)
|
||||||
deliverySlotService := services.NewDeliverySlotService(deliverySlotRepo)
|
deliverySlotService := services.NewDeliverySlotService(deliverySlotRepo)
|
||||||
deliverySlotController := controllers.NewDeliverySlotController(deliverySlotService)
|
deliverySlotController := controllers.NewDeliverySlotController(deliverySlotService, tenantService)
|
||||||
|
|
||||||
// Order Module
|
// Order Module
|
||||||
orderRepo := repositories.NewOrderRepository(db)
|
orderRepo := repositories.NewOrderRepository(db)
|
||||||
orderService := services.NewOrderService(orderRepo)
|
orderService := services.NewOrderService(orderRepo)
|
||||||
orderController := controllers.NewOrderController(orderService, deliverySlotService)
|
orderController := controllers.NewOrderController(orderService, deliverySlotService, tenantService)
|
||||||
|
|
||||||
// Deliveries Module
|
// Deliveries Module
|
||||||
deliveriesRepo := repositories.NewDeliveriesRepository(db)
|
deliveriesRepo := repositories.NewDeliveriesRepository(db)
|
||||||
@@ -117,14 +129,6 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat
|
|||||||
utilsService := services.NewUtilsService(utilsRepo)
|
utilsService := services.NewUtilsService(utilsRepo)
|
||||||
utilsController := controllers.NewUtilsController(utilsService)
|
utilsController := controllers.NewUtilsController(utilsService)
|
||||||
|
|
||||||
//Tenant Module
|
|
||||||
//
|
|
||||||
// Onboarding, adding a person and commissioning a branch all create an
|
|
||||||
// account with no password, so all three send an invitation. `tenantRepo` and
|
|
||||||
// `inviteService` are built above, where the reasoning is.
|
|
||||||
tenantService := services.NewTenantService(tenantRepo, inviteService)
|
|
||||||
tenantController := controllers.NewTenantController(tenantService)
|
|
||||||
|
|
||||||
//Partner Module
|
//Partner Module
|
||||||
partnerRepo := repositories.NewPartnerRepository(db)
|
partnerRepo := repositories.NewPartnerRepository(db)
|
||||||
partnerService := services.NewPartnerService(partnerRepo)
|
partnerService := services.NewPartnerService(partnerRepo)
|
||||||
|
|||||||
26
main.go
26
main.go
@@ -251,6 +251,32 @@ func main() {
|
|||||||
log.Println("⚠️ could not add orders.deliveryslotdate, orders will not record which day their window falls on:", err)
|
log.Println("⚠️ could not add orders.deliveryslotdate, orders will not record which day their window falls on:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether a branch is trading today.
|
||||||
|
//
|
||||||
|
// SEPARATE FROM tenantlocations.status, which is the platform's word for
|
||||||
|
// whether the branch exists at all — setting that to Inactive is how a
|
||||||
|
// branch is decommissioned, and the staff console reads it that way. A
|
||||||
|
// shopkeeper closing for an afternoon is saying something else entirely,
|
||||||
|
// and sharing one column would make a day off look like a shop that shut
|
||||||
|
// down.
|
||||||
|
//
|
||||||
|
// Default TRUE, so every branch trading the moment this ships keeps
|
||||||
|
// trading. There is no backfill and there must never be one.
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`ALTER TABLE tenantlocations ADD COLUMN IF NOT EXISTS isopen boolean NOT NULL DEFAULT true`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not add tenantlocations.isopen, every branch will keep reporting as open:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The day it opens again, when the shop knows it.
|
||||||
|
//
|
||||||
|
// Nullable, because a power cut has no end date. Nothing flips isopen back
|
||||||
|
// on a timer — models.StoreIsOpen works the answer out on read, so a branch
|
||||||
|
// that said "back on Monday" is open on Monday whether or not a job ran.
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`ALTER TABLE tenantlocations ADD COLUMN IF NOT EXISTS closeduntil DATE`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not add tenantlocations.closeduntil, branches will not reopen on their own:", err)
|
||||||
|
}
|
||||||
|
|
||||||
// When a product became visible to a store, and the only thing that decides
|
// When a product became visible to a store, and the only thing that decides
|
||||||
// whether it is.
|
// whether it is.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -467,6 +467,31 @@ type TenantInfo struct {
|
|||||||
Categoryid int `json:"categoryid"`
|
Categoryid int `json:"categoryid"`
|
||||||
Registrationno string `json:"registrationno"`
|
Registrationno string `json:"registrationno"`
|
||||||
Orderscount int `json:"orderscount"`
|
Orderscount int `json:"orderscount"`
|
||||||
|
|
||||||
|
/*
|
||||||
|
Whether this branch is taking orders, and why not when it is not.
|
||||||
|
|
||||||
|
`Isopen` and `Closeduntil` are the stored columns; `Isaccepting` and
|
||||||
|
`Closedreason` are worked out per request by models.StoreIsOpen, which
|
||||||
|
also folds in `status` and lets a dated close expire on its own.
|
||||||
|
|
||||||
|
THE APP SHOULD READ `isaccepting`, not `isopen`. The raw flag says what
|
||||||
|
the shopkeeper last pressed; the computed one is the answer — a branch
|
||||||
|
that said "back on Monday" has isopen false on Monday and is open.
|
||||||
|
|
||||||
|
The branch is still RETURNED when closed rather than filtered out. A shop
|
||||||
|
that vanishes reads to a regular customer as gone for good; one marked
|
||||||
|
"closed today" brings them back tomorrow. An app that would rather hide
|
||||||
|
it can, from this field.
|
||||||
|
*/
|
||||||
|
// The BRANCH status, aliased in the query so it does not collide with the
|
||||||
|
// tenant status already on this struct. Feeds StoreIsOpen.
|
||||||
|
Locationstatus string `json:"-" gorm:"column:locationstatus"`
|
||||||
|
Isopen bool `json:"isopen"`
|
||||||
|
Closeduntil string `json:"closeduntil"`
|
||||||
|
Isaccepting bool `json:"isaccepting" gorm:"-"`
|
||||||
|
Closedreason string `json:"closedreason" gorm:"-"`
|
||||||
|
|
||||||
// Products []Products `json:"products" gorm:"-"`
|
// Products []Products `json:"products" gorm:"-"`
|
||||||
ProductSubcategory []ProductSubcategory `json:"productsubcategory" gorm:"-"`
|
ProductSubcategory []ProductSubcategory `json:"productsubcategory" gorm:"-"`
|
||||||
}
|
}
|
||||||
|
|||||||
89
models/storeopen.go
Normal file
89
models/storeopen.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Is a branch taking orders right now?
|
||||||
|
|
||||||
|
Two things have to be true: the branch exists as far as the platform is
|
||||||
|
concerned, and the shop has not closed itself for the day. They are different
|
||||||
|
questions with different owners — Nearle decommissions a branch, a shopkeeper
|
||||||
|
closes for an afternoon — and this is the only place they are answered
|
||||||
|
together.
|
||||||
|
|
||||||
|
── Reopening is worked out, not scheduled ──────────────────────────────────
|
||||||
|
|
||||||
|
A shop that said "back on Monday" is open on Monday because this function says
|
||||||
|
so, not because a job ran. Nothing has to fire, nothing can be missed, and a
|
||||||
|
server that was down over the weekend still gets it right. The alternative — a
|
||||||
|
nightly task flipping the flag — fails silently exactly once and leaves a shop
|
||||||
|
shut with nobody watching.
|
||||||
|
|
||||||
|
── Why the date means the day it REOPENS ───────────────────────────────────
|
||||||
|
|
||||||
|
"Closed until the 12th" reads, in English, as open on the 12th. So the date
|
||||||
|
names the first day back, and a shop closing for today alone sets tomorrow.
|
||||||
|
The console says "Reopens on" rather than "Closed until" so nobody has to
|
||||||
|
work that out.
|
||||||
|
*/
|
||||||
|
func StoreIsOpen(status string, isOpen bool, closedUntil string, now time.Time) bool {
|
||||||
|
// Decommissioned beats everything. A branch Nearle has switched off does
|
||||||
|
// not come back because a date passed.
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(status), "active") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if isOpen {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closed, with a date to come back on.
|
||||||
|
reopen, ok := parseStoreDate(closedUntil)
|
||||||
|
if !ok {
|
||||||
|
// Closed with no end in sight — a power cut nobody can put a date on.
|
||||||
|
// Stays closed until a person says otherwise, which is the safe way
|
||||||
|
// round: a shop wrongly shown open takes orders it cannot fill.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
today := now.Truncate(24 * time.Hour)
|
||||||
|
return !today.Before(reopen.Truncate(24 * time.Hour))
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Why a shopper cannot order, in words fit to show them.
|
||||||
|
|
||||||
|
Empty when the branch is open. Returned straight to the app, so it says what a
|
||||||
|
person needs to know and not what the database holds.
|
||||||
|
*/
|
||||||
|
func StoreClosedReason(status string, isOpen bool, closedUntil string, now time.Time) string {
|
||||||
|
if StoreIsOpen(status, isOpen, closedUntil, now) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(status), "active") {
|
||||||
|
return "This store is not currently available"
|
||||||
|
}
|
||||||
|
if reopen, ok := parseStoreDate(closedUntil); ok {
|
||||||
|
return "This store is closed today and reopens on " + reopen.Format("2 Jan")
|
||||||
|
}
|
||||||
|
return "This store is closed today"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepts a bare date or a timestamp, because the column is a DATE but GORM
|
||||||
|
// and the console have both been known to hand over a full RFC3339 string.
|
||||||
|
// Anything unreadable is "no date", never today.
|
||||||
|
func parseStoreDate(value string) (time.Time, bool) {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
if len(trimmed) >= 10 {
|
||||||
|
if parsed, err := time.Parse("2006-01-02", trimmed[:10]); err == nil {
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
111
models/storeopen_test.go
Normal file
111
models/storeopen_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func at(day string) time.Time {
|
||||||
|
parsed, err := time.Parse("2006-01-02", day)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenByDefault(t *testing.T) {
|
||||||
|
// Every branch trading today has isopen true and no date. None of them may
|
||||||
|
// go dark the moment this ships.
|
||||||
|
if !StoreIsOpen("Active", true, "", at("2026-10-09")) {
|
||||||
|
t.Error("a normal trading branch was reported closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClosedWithNoDateStaysClosed(t *testing.T) {
|
||||||
|
// A power cut nobody can put an end to. It stays shut until a person says
|
||||||
|
// otherwise — the safe way round, because a shop wrongly shown open takes
|
||||||
|
// orders it cannot fill.
|
||||||
|
if StoreIsOpen("Active", false, "", at("2026-10-09")) {
|
||||||
|
t.Error("a branch closed with no reopen date was reported open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReopensOnTheDateItNamed(t *testing.T) {
|
||||||
|
// "Closed until the 12th" means open ON the 12th.
|
||||||
|
cases := []struct {
|
||||||
|
today string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"2026-10-10", false}, // still closed
|
||||||
|
{"2026-10-11", false}, // last closed day
|
||||||
|
{"2026-10-12", true}, // back
|
||||||
|
{"2026-10-13", true}, // and stays back
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := StoreIsOpen("Active", false, "2026-10-12", at(tc.today))
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("on %s: open=%v, want %v", tc.today, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReopeningNeedsNobodyToRemember(t *testing.T) {
|
||||||
|
// The whole reason this is computed and not scheduled: no job ran, nothing
|
||||||
|
// flipped the flag, and the shop is open anyway.
|
||||||
|
if !StoreIsOpen("Active", false, "2026-10-01", at("2026-12-25")) {
|
||||||
|
t.Error("a branch whose reopen date passed months ago is still shut")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecommissionedBeatsEverything(t *testing.T) {
|
||||||
|
// Status is Nearle's word for whether the branch exists. A date passing
|
||||||
|
// must not bring back a branch that was switched off.
|
||||||
|
if StoreIsOpen("Inactive", true, "", at("2026-10-09")) {
|
||||||
|
t.Error("an inactive branch was reported open")
|
||||||
|
}
|
||||||
|
if StoreIsOpen("Inactive", false, "2026-10-01", at("2026-12-25")) {
|
||||||
|
t.Error("an inactive branch came back because a date passed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnreadableDateIsNotTreatedAsToday(t *testing.T) {
|
||||||
|
// A zero time would be before today and quietly reopen the shop.
|
||||||
|
if StoreIsOpen("Active", false, "not a date", at("2026-10-09")) {
|
||||||
|
t.Error("an unreadable reopen date reopened the branch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcceptsATimestampAsWellAsADate(t *testing.T) {
|
||||||
|
// The column is a DATE, but GORM and the console have both been known to
|
||||||
|
// hand over a full timestamp.
|
||||||
|
if !StoreIsOpen("Active", false, "2026-10-12T00:00:00Z", at("2026-10-12")) {
|
||||||
|
t.Error("a timestamp form of the reopen date was not understood")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReasonIsWrittenForAShopper(t *testing.T) {
|
||||||
|
if msg := StoreClosedReason("Active", true, "", at("2026-10-09")); msg != "" {
|
||||||
|
t.Errorf("an open store gave a reason: %q", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := StoreClosedReason("Active", false, "2026-10-12", at("2026-10-09"))
|
||||||
|
if !strings.Contains(msg, "12 Oct") {
|
||||||
|
t.Errorf("the reason does not say when it reopens: %q", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No date: say it is closed, and do not invent a return.
|
||||||
|
plain := StoreClosedReason("Active", false, "", at("2026-10-09"))
|
||||||
|
if plain == "" || strings.Contains(plain, "reopens") {
|
||||||
|
t.Errorf("a close with no date promised a return: %q", plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusIsNotCaseSensitive(t *testing.T) {
|
||||||
|
// The column holds "Active" today, but nothing enforces the capital.
|
||||||
|
for _, s := range []string{"Active", "active", "ACTIVE", " Active "} {
|
||||||
|
if !StoreIsOpen(s, true, "", at("2026-10-09")) {
|
||||||
|
t.Errorf("status %q read as not active", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,8 +31,8 @@ type Tenantinfo struct {
|
|||||||
// result set and thrown away for want of a field to scan into. A shop
|
// result set and thrown away for want of a field to scan into. A shop
|
||||||
// profile that cannot say how long the business has been on the platform is
|
// profile that cannot say how long the business has been on the platform is
|
||||||
// missing a fact the database has held all along.
|
// missing a fact the database has held all along.
|
||||||
Created time.Time `json:"created"`
|
Created time.Time `json:"created"`
|
||||||
Tenantinfo string `json:"tenantinfo"`
|
Tenantinfo string `json:"tenantinfo"`
|
||||||
// The FSSAI or trade licence.
|
// The FSSAI or trade licence.
|
||||||
//
|
//
|
||||||
// The column has always existed and `GetCustomerTenants` already selects it
|
// The column has always existed and `GetCustomerTenants` already selects it
|
||||||
@@ -63,14 +63,14 @@ type Tenantinfo struct {
|
|||||||
// `b.categoryname`, and the struct had nowhere to put it — so the value was
|
// `b.categoryname`, and the struct had nowhere to put it — so the value was
|
||||||
// computed on every read and discarded, leaving the console to print
|
// computed on every read and discarded, leaving the console to print
|
||||||
// "Category 2" at a merchant who trades under Daily Needs.
|
// "Category 2" at a merchant who trades under Daily Needs.
|
||||||
Categoryname string `json:"categoryname"`
|
Categoryname string `json:"categoryname"`
|
||||||
Firstname string `json:"firstname"`
|
Firstname string `json:"firstname"`
|
||||||
Lastname string `json:"lastname"`
|
Lastname string `json:"lastname"`
|
||||||
Accountname string `json:"Accountname"`
|
Accountname string `json:"Accountname"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Allocationid int `json:"allocationid"`
|
Allocationid int `json:"allocationid"`
|
||||||
Allocationtype string `json:"allocationtype"`
|
Allocationtype string `json:"allocationtype"`
|
||||||
Allocationmode int `json:"allocationmode"`
|
Allocationmode int `json:"allocationmode"`
|
||||||
|
|
||||||
// How many outlets this merchant has.
|
// How many outlets this merchant has.
|
||||||
//
|
//
|
||||||
@@ -105,6 +105,35 @@ type Tenantlocations struct {
|
|||||||
Cancelsecs int `json:"cancelsecs"`
|
Cancelsecs int `json:"cancelsecs"`
|
||||||
Status string `json:"status" gorm:"default:Active"`
|
Status string `json:"status" gorm:"default:Active"`
|
||||||
|
|
||||||
|
/*
|
||||||
|
Is this branch trading today?
|
||||||
|
|
||||||
|
SEPARATE FROM `Status`, deliberately. Status is the platform's word for
|
||||||
|
whether a branch exists at all — setting it to Inactive is how a branch is
|
||||||
|
decommissioned, and the staff console reads it that way. A shopkeeper
|
||||||
|
closing for an afternoon because the power went out is saying something
|
||||||
|
completely different, and the two must not share a field: a day off would
|
||||||
|
read to Nearle as a shop that had shut down.
|
||||||
|
|
||||||
|
Default true. Every branch trading today keeps trading.
|
||||||
|
*/
|
||||||
|
Isopen bool `json:"isopen" gorm:"column:isopen;default:true"`
|
||||||
|
|
||||||
|
/*
|
||||||
|
The date it opens again, when the shop knows it.
|
||||||
|
|
||||||
|
"Closed until the 12th" means closed on the 11th and OPEN on the 12th —
|
||||||
|
the date names the first day back, which is how the phrase reads.
|
||||||
|
|
||||||
|
Nothing flips `Isopen` back on a timer. The answer is worked out on read
|
||||||
|
(see StoreIsOpen), so a branch that said "back on Monday" is open on
|
||||||
|
Monday whether or not anybody remembered. A job would drift; this cannot.
|
||||||
|
|
||||||
|
Empty means closed until somebody says otherwise, which is the right
|
||||||
|
default for a power cut nobody can put an end date on.
|
||||||
|
*/
|
||||||
|
Closeduntil string `json:"closeduntil" gorm:"column:closeduntil"`
|
||||||
|
|
||||||
// Who will run this outlet, when the caller already has somebody in mind.
|
// Who will run this outlet, when the caller already has somebody in mind.
|
||||||
//
|
//
|
||||||
// `gorm:"-"` because it is not a column — it names an existing `app_users`
|
// `gorm:"-"` because it is not a column — it names an existing `app_users`
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ type TenantRepository interface {
|
|||||||
GetTenantSlot() (models.Tenantslot, error)
|
GetTenantSlot() (models.Tenantslot, error)
|
||||||
CreateTenantCustomer(customer models.Tenantcustomer) (*models.Tenantcustomer, error)
|
CreateTenantCustomer(customer models.Tenantcustomer) (*models.Tenantcustomer, error)
|
||||||
GetCustomerTenants(customerID int, categoryID int, tenantFlag int) ([]models.TenantInfo, error)
|
GetCustomerTenants(customerID int, categoryID int, tenantFlag int) ([]models.TenantInfo, error)
|
||||||
|
SetStoreOpen(tenantID, locationID int, isOpen bool, closedUntil string) error
|
||||||
|
StoreOpenState(tenantID, locationID int) (status string, isOpen bool, closedUntil string, err error)
|
||||||
GetTenantPricing(tid, aid int) (*models.Tenantpricing, error)
|
GetTenantPricing(tid, aid int) (*models.Tenantpricing, error)
|
||||||
UpdateLocation(input models.Tenantlocations) error
|
UpdateLocation(input models.Tenantlocations) error
|
||||||
CreateLocation(data models.Tenantlocations) error
|
CreateLocation(data models.Tenantlocations) error
|
||||||
@@ -219,7 +221,14 @@ func (r *tenantRepository) GetCustomerTenants(customerID int, categoryID int, te
|
|||||||
b.primaryemail,b.primarycontact,b.applocationid,b.suburb,b.city,
|
b.primaryemail,b.primarycontact,b.applocationid,b.suburb,b.city,
|
||||||
b.latitude,b.longitude,b.postcode,b.tenantimage,b.subcategoryid,
|
b.latitude,b.longitude,b.postcode,b.tenantimage,b.subcategoryid,
|
||||||
b.categoryid,b.registrationno,d.userfcmtoken,c.locationname,
|
b.categoryid,b.registrationno,d.userfcmtoken,c.locationname,
|
||||||
COALESCE(o.orderscount, 0) AS orderscount
|
COALESCE(o.orderscount, 0) AS orderscount,
|
||||||
|
-- Whether the shop is trading today. Selected, never filtered on:
|
||||||
|
-- a closed branch still comes back so the app can show it greyed
|
||||||
|
-- rather than make a regular customer think it has gone for good.
|
||||||
|
-- COALESCE because the column is new and older rows predate it.
|
||||||
|
COALESCE(c.isopen, true) AS isopen,
|
||||||
|
COALESCE(c.closeduntil::text, '') AS closeduntil,
|
||||||
|
c.status AS locationstatus
|
||||||
FROM tenantcustomers a
|
FROM tenantcustomers a
|
||||||
LEFT JOIN tenants b ON a.tenantid = b.tenantid
|
LEFT JOIN tenants b ON a.tenantid = b.tenantid
|
||||||
INNER JOIN tenantlocations c ON a.locationid = c.locationid
|
INNER JOIN tenantlocations c ON a.locationid = c.locationid
|
||||||
@@ -1210,3 +1219,65 @@ func (r *tenantRepository) TenantNameByID(tenantID int) (string, error) {
|
|||||||
tenantID).Scan(&name).Error
|
tenantID).Scan(&name).Error
|
||||||
return name, err
|
return name, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Open or close a branch for the day.
|
||||||
|
|
||||||
|
Scoped by tenant as well as branch: a locationid arrives from a console
|
||||||
|
request, and looking it up by id alone would let one business close another
|
||||||
|
business's shop.
|
||||||
|
|
||||||
|
`closeduntil` is written as NULL when empty rather than as an empty string, so
|
||||||
|
the column holds a real date or nothing — a ” in a DATE column is an error in
|
||||||
|
Postgres, and the shape of "closed with no end in sight" is the absence of a
|
||||||
|
date, not a blank one.
|
||||||
|
*/
|
||||||
|
func (r *tenantRepository) SetStoreOpen(tenantID, locationID int, isOpen bool, closedUntil string) error {
|
||||||
|
updates := map[string]interface{}{"isopen": isOpen}
|
||||||
|
|
||||||
|
if trimmed := strings.TrimSpace(closedUntil); trimmed != "" && !isOpen {
|
||||||
|
updates["closeduntil"] = trimmed
|
||||||
|
} else {
|
||||||
|
// Reopening clears the date too. A branch switched back on must not
|
||||||
|
// keep a stale "reopens on" that would read as still closed.
|
||||||
|
updates["closeduntil"] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := r.db.Table("tenantlocations").
|
||||||
|
Where("locationid = ? AND tenantid = ?", locationID, tenantID).
|
||||||
|
Updates(updates)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return fmt.Errorf("branch %d was not found for this business", locationID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three columns models.StoreIsOpen needs, for one branch.
|
||||||
|
//
|
||||||
|
// Returns an OPEN state when the branch is not found. Order creation calls
|
||||||
|
// this, and a lookup that missed must not become a refusal to trade: the
|
||||||
|
// endpoint already takes orders from callers that name no branch at all, and
|
||||||
|
// turning a missing row into "we are closed" would stop sales on a data
|
||||||
|
// problem rather than a business decision.
|
||||||
|
func (r *tenantRepository) StoreOpenState(tenantID, locationID int) (string, bool, string, error) {
|
||||||
|
var row struct {
|
||||||
|
Status string
|
||||||
|
Isopen bool
|
||||||
|
Closeduntil string
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.db.Table("tenantlocations").
|
||||||
|
Select("status, COALESCE(isopen, true) AS isopen, COALESCE(closeduntil::text, '') AS closeduntil").
|
||||||
|
Where("locationid = ? AND tenantid = ?", locationID, tenantID).
|
||||||
|
Scan(&row).Error
|
||||||
|
if err != nil {
|
||||||
|
return "Active", true, "", err
|
||||||
|
}
|
||||||
|
if row.Status == "" {
|
||||||
|
return "Active", true, "", nil
|
||||||
|
}
|
||||||
|
return row.Status, row.Isopen, row.Closeduntil, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ func RegisterTenantRoutes(api fiber.Router, f *facade.Facade) {
|
|||||||
tenant.Post("/createlocation", f.TenantController.CreateLocation)
|
tenant.Post("/createlocation", f.TenantController.CreateLocation)
|
||||||
tenant.Delete("/deletelocation", f.TenantController.DeleteLocation)
|
tenant.Delete("/deletelocation", f.TenantController.DeleteLocation)
|
||||||
tenant.Post("/createtenantlocation", f.TenantController.CreateTenantLocation)
|
tenant.Post("/createtenantlocation", f.TenantController.CreateTenantLocation)
|
||||||
|
// Guarded on purpose: closing a shop stops it taking money, and the mob
|
||||||
|
// group carries no session at all.
|
||||||
|
tenant.Put("/storeopen", f.TenantController.SetStoreOpen)
|
||||||
tenant.Put("/updatetenantlocation", f.TenantController.UpdateTenantLocation)
|
tenant.Put("/updatetenantlocation", f.TenantController.UpdateTenantLocation)
|
||||||
tenant.Post("/createtenantuser", f.TenantController.CreateTenantUser)
|
tenant.Post("/createtenantuser", f.TenantController.CreateTenantUser)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -19,6 +21,8 @@ type TenantService interface {
|
|||||||
CreateTenantCustomer(req models.CreateTenantCustomerRequest) (*models.Tenantcustomer, error)
|
CreateTenantCustomer(req models.CreateTenantCustomerRequest) (*models.Tenantcustomer, error)
|
||||||
AssignPartner(tenantID, partnerID int) error
|
AssignPartner(tenantID, partnerID int) error
|
||||||
GetCustomerTenants(customerID int, categoryID int, tenantFlag int) (*models.CustomerTenantResponse, error)
|
GetCustomerTenants(customerID int, categoryID int, tenantFlag int) (*models.CustomerTenantResponse, error)
|
||||||
|
SetStoreOpen(tenantID, locationID int, isOpen bool, closedUntil string) error
|
||||||
|
StoreClosedReason(tenantID, locationID int) string
|
||||||
GetTenantPricing(tid, aid int) (models.Tenantpricing, error)
|
GetTenantPricing(tid, aid int) (models.Tenantpricing, error)
|
||||||
UpdateLocation(input models.Tenantlocations) error
|
UpdateLocation(input models.Tenantlocations) error
|
||||||
CreateLocation(data models.Tenantlocations) error
|
CreateLocation(data models.Tenantlocations) error
|
||||||
@@ -98,6 +102,27 @@ func (s *tenantService) GetCustomerTenants(customerID int, categoryID int, tenan
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Whether each branch is actually taking orders.
|
||||||
|
|
||||||
|
Worked out here rather than in SQL so there is ONE implementation of the
|
||||||
|
rule — order creation calls the same function before accepting an order,
|
||||||
|
and a shop the app showed as open while the server refused it would be a
|
||||||
|
shopper staring at an error nothing on screen explains.
|
||||||
|
|
||||||
|
`now` is read once for the whole list: a request that straddled midnight
|
||||||
|
could otherwise return two branches on different days.
|
||||||
|
*/
|
||||||
|
now := time.Now()
|
||||||
|
for i := range details {
|
||||||
|
details[i].Isaccepting = models.StoreIsOpen(
|
||||||
|
details[i].Locationstatus, details[i].Isopen, details[i].Closeduntil, now,
|
||||||
|
)
|
||||||
|
details[i].Closedreason = models.StoreClosedReason(
|
||||||
|
details[i].Locationstatus, details[i].Isopen, details[i].Closeduntil, now,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return &models.CustomerTenantResponse{
|
return &models.CustomerTenantResponse{
|
||||||
Details: details,
|
Details: details,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -467,3 +492,53 @@ func (s *tenantService) resendTo(target repositories.InviteTarget, tenantID int)
|
|||||||
sent, reason := s.invites.Invite(target.Userid, tenantID, target.Email, target.Tenantname)
|
sent, reason := s.invites.Invite(target.Userid, tenantID, target.Email, target.Tenantname)
|
||||||
return InviteOutcome{Sent: sent, Reason: reason}, nil
|
return InviteOutcome{Sent: sent, Reason: reason}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Open or close a branch for the day.
|
||||||
|
|
||||||
|
Refuses a reopen date in the past outright. "Closed until yesterday" is a shop
|
||||||
|
that is open, so storing it would mean the console showing closed while the
|
||||||
|
app showed open — the two disagreeing about the same branch is worse than the
|
||||||
|
edit failing.
|
||||||
|
*/
|
||||||
|
func (s *tenantService) SetStoreOpen(tenantID, locationID int, isOpen bool, closedUntil string) error {
|
||||||
|
if tenantID <= 0 || locationID <= 0 {
|
||||||
|
return fmt.Errorf("tenantid and locationid are both required")
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed := strings.TrimSpace(closedUntil)
|
||||||
|
if !isOpen && trimmed != "" {
|
||||||
|
reopen, err := time.Parse("2006-01-02", trimmed)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("the reopening date must be written as YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
today := time.Now().Truncate(24 * time.Hour)
|
||||||
|
if reopen.Truncate(24 * time.Hour).Before(today) {
|
||||||
|
return fmt.Errorf("that reopening date has already passed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.SetStoreOpen(tenantID, locationID, isOpen, trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Why this branch cannot take an order, in words fit to show a shopper.
|
||||||
|
|
||||||
|
Empty when it can. Called by order creation, and it leans on the same
|
||||||
|
models.StoreIsOpen the customer-facing list uses — so a shop the app showed as
|
||||||
|
open is never refused here, and one it showed as closed is never quietly
|
||||||
|
accepted.
|
||||||
|
|
||||||
|
Any failure reading the branch returns "" — open. A database hiccup must not
|
||||||
|
stop a shop trading.
|
||||||
|
*/
|
||||||
|
func (s *tenantService) StoreClosedReason(tenantID, locationID int) string {
|
||||||
|
if tenantID <= 0 || locationID <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
status, isOpen, closedUntil, err := s.repo.StoreOpenState(tenantID, locationID)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return models.StoreClosedReason(status, isOpen, closedUntil, time.Now())
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user