Files
backend_fiesta/controllers/posController.go
Suriya ec672a3087 Add an HTTP heartbeat, and file bills under the till that rang them
Two faults found while checking whether today's live bills had landed. They
had — 17 of them, complete — but both of these were sitting in the same data.

**Health existed only over MQTT.** The consumer subscribes to the health topic
and has done since startup, but a terminal on the HTTP route has no way to
reach it. Today's terminal was on HTTP, so it reported nothing and the board
showed "online 0 of 1" while the till was demonstrably alive and selling.
POST /pos/health now takes the same payload the broker carries, into the same
Redis record, so the board cannot tell the two routes apart and does not need
to. It answers 202 and swallows failures: a till that cannot say how it is must
still sell.

**terminalid was empty on 16 of 17 bills.** The consumer backfills a missing
terminal code from the topic, but onto the batch, while the row was built from
the order — the two never met, and importPosOrder was not handed the batch's
value at all. Over HTTP there was no topic to fall back on either. So the
invoice numbers read INV-2608-T5EDD-000NN while the column they should have
matched was blank, and `byterminal` on the sales summary grouped almost
everything under "". The bill's own terminal now wins with the batch's as the
fallback, trimmed, so whitespace is not mistaken for a code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:48:24 +05:30

379 lines
12 KiB
Go

package controllers
import (
"fmt"
"log"
"net/http"
"strconv"
"strings"
"nearle/models"
"nearle/services"
"github.com/gofiber/fiber/v2"
)
// HTTP face of the POS terminal ingest.
//
// These handlers break this codebase's house style in one respect, on purpose:
// they answer with a bare ack rather than the usual
// `{code, message, status, details}` envelope. The terminal reads `accepted`
// from the top level of the body and marks a bill synced only if its id is
// there — wrapping the ack would leave every till queueing for ever.
//
// The status code carries the other half of the contract:
//
// - **200** — the batch was processed. Individual bills may still have been
// refused; the ack says which.
// - **4xx** — the request itself is wrong (unreadable body, unknown outlet).
// The terminal treats these as non-retryable and halts, so a person is
// told rather than the broker hammered.
// - **5xx** — the outcome is unknown. The terminal keeps every bill and
// retries with backoff. This is the right answer when the database is
// having a bad minute: *never* ack a batch that did not commit.
type PosController struct {
posService services.PosService
}
func NewPosController(posService services.PosService) *PosController {
return &PosController{posService: posService}
}
// IngestOrders receives a batch of completed counter bills.
func (ctl *PosController) IngestOrders(c *fiber.Ctx) error {
var batch models.PosOrderBatch
if err := c.BodyParser(&batch); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "could not read the batch: " + err.Error(),
"status": false,
})
}
if strings.TrimSpace(batch.Storeid) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "store_id is required",
"status": false,
})
}
ack, err := ctl.posService.IngestOrders(batch)
if err != nil {
return posIngestError(c, "IngestOrders", err)
}
return c.Status(http.StatusOK).JSON(ack)
}
// IngestCustomers receives shoppers registered at a till.
func (ctl *PosController) IngestCustomers(c *fiber.Ctx) error {
var batch models.PosCustomerBatch
if err := c.BodyParser(&batch); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "could not read the batch: " + err.Error(),
"status": false,
})
}
if strings.TrimSpace(batch.Storeid) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "store_id is required",
"status": false,
})
}
ack, err := ctl.posService.IngestCustomers(batch)
if err != nil {
return posIngestError(c, "IngestCustomers", err)
}
return c.Status(http.StatusOK).JSON(ack)
}
// IngestHealth records one heartbeat from a till.
//
// The same heartbeat the broker carries, over HTTP, because presence was
// previously reachable *only* over MQTT — a terminal configured for the HTTP
// route reported bills perfectly and never appeared on the fleet board at all,
// with nothing anywhere to say why. A monitoring feature that silently does not
// exist on one of two supported transports is worse than no feature.
//
// Answers 202 rather than 200: nothing is committed, and the till is told not
// to wait on it. Failures are swallowed for the same reason the MQTT path
// swallows them — a terminal that cannot say how it is must still sell, and a
// blank square on a dashboard beats a till that stopped because Redis was busy.
func (ctl *PosController) IngestHealth(c *fiber.Ctx) error {
var health models.PosHealth
if err := c.BodyParser(&health); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "could not read the heartbeat: " + err.Error(),
"status": false,
})
}
// Over MQTT these come from the topic. There is no topic here, so the body
// is the only source and both are required — a heartbeat that cannot say
// which till it belongs to is unfilable.
if strings.TrimSpace(health.Terminalid) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false,
"message": "terminal_id is required",
})
}
if strings.TrimSpace(health.Locationid) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false,
"message": "location_id is required",
})
}
// Matches the consumer: a bare {"status":"offline"} is a Last Will and must
// survive as-is, but an unset status from a till that is plainly talking to
// us means online.
if strings.TrimSpace(health.Status) == "" {
health.Status = "online"
}
if err := ctl.posService.RecordHealth(c.Context(), health); err != nil {
// Logged, not returned. See above — the till must not slow down for it.
log.Printf("pos: could not record heartbeat from %s/%s over HTTP: %v",
health.Locationid, health.Terminalid, err)
}
return c.Status(http.StatusAccepted).JSON(fiber.Map{
"status": true, "code": http.StatusAccepted,
})
}
// Catalogue answers a terminal's product pull.
func (ctl *PosController) Catalogue(c *fiber.Ctx) error {
storeID := strings.TrimSpace(c.Query("store_id"))
if storeID == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "store_id is required",
"status": false,
})
}
page, _ := strconv.Atoi(c.Query("page", "0"))
pageSize, _ := strconv.Atoi(c.Query("page_size", "500"))
result, err := ctl.posService.Catalogue(storeID, c.Query("since"), page, pageSize)
if err != nil {
return posIngestError(c, "Catalogue", err)
}
return c.Status(http.StatusOK).JSON(result)
}
// TerminalHealth returns one till's live state, for a support call that starts
// with a terminal code.
func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error {
terminalID := strings.TrimSpace(c.Query("terminal_id"))
if terminalID == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": "terminal_id is required", "status": false,
})
}
fields, err := ctl.posService.TerminalHealth(c.Context(), terminalID)
if err != nil {
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
"code": http.StatusServiceUnavailable, "message": err.Error(), "status": false,
})
}
if fields == nil {
// Not an error. The till has simply not reported inside its TTL, which
// is the answer the caller wanted — said plainly rather than as a 404
// that reads like the terminal does not exist.
return c.JSON(fiber.Map{
"code": http.StatusOK,
"status": true,
"details": fiber.Map{
"terminal_id": terminalID,
"status": "offline",
"reason": "no heartbeat received within the presence window",
},
})
}
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": fields})
}
// LocationHealth returns every till at a shop — the "which counters are dark"
// board. Tills that have stopped reporting come back marked offline rather than
// being omitted, because a missing till is exactly what somebody is looking for.
func (ctl *PosController) LocationHealth(c *fiber.Ctx) error {
locationID := strings.TrimSpace(c.Query("location_id"))
if locationID == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": "location_id is required", "status": false,
})
}
terminals, err := ctl.posService.LocationHealth(c.Context(), locationID)
if err != nil {
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
"code": http.StatusServiceUnavailable, "message": err.Error(), "status": false,
})
}
online := 0
for _, t := range terminals {
if t["status"] == "online" {
online++
}
}
return c.JSON(fiber.Map{
"code": http.StatusOK,
"status": true,
"details": fiber.Map{
"location_id": locationID,
"total": len(terminals),
"online": online,
"terminals": terminals,
},
})
}
// ---------------------------------------------------------------- Sales reads
//
// Unlike the ingest handlers above, these answer in the usual
// `{code, message, status, details}` envelope — they are read by the web app,
// not by a terminal, and nothing about them is bound to the till's contract.
// posSalesFilter reads the shared query parameters.
func posSalesFilter(c *fiber.Ctx) (models.PosSalesFilter, error) {
locationID, err := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
if err != nil || locationID <= 0 {
return models.PosSalesFilter{}, fmt.Errorf("locationid is required")
}
pageno, _ := strconv.Atoi(c.Query("pageno", "0"))
pagesize, _ := strconv.Atoi(c.Query("pagesize", "50"))
return models.PosSalesFilter{
Locationid: locationID,
Fromdate: strings.TrimSpace(c.Query("fromdate")),
Todate: strings.TrimSpace(c.Query("todate")),
Terminalid: strings.TrimSpace(c.Query("terminalid")),
Cashiername: strings.TrimSpace(c.Query("cashiername")),
Paymentmode: strings.TrimSpace(c.Query("paymentmode")),
Pageno: pageno,
Pagesize: pagesize,
}, nil
}
// GetSales lists counter bills for an outlet, newest first.
func (ctl *PosController) GetSales(c *fiber.Ctx) error {
filter, err := posSalesFilter(c)
if err != nil {
return posBadRequest(c, err)
}
page, err := ctl.posService.Sales(filter)
if err != nil {
return posServerError(c, "GetSales", err)
}
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": page})
}
// GetSaleDetail returns one bill with its lines.
//
// Accepts the terminal's order UUID, the invoice number, or this backend's
// posorderid — a support call starts from whichever the caller is looking at.
func (ctl *PosController) GetSaleDetail(c *fiber.Ctx) error {
locationID, err := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
if err != nil || locationID <= 0 {
return posBadRequest(c, fmt.Errorf("locationid is required"))
}
reference := strings.TrimSpace(c.Query("reference"))
if reference == "" {
return posBadRequest(c, fmt.Errorf("reference is required — an order id, invoice number or posorderid"))
}
bill, err := ctl.posService.SaleDetail(locationID, reference)
if err != nil {
return posServerError(c, "GetSaleDetail", err)
}
if bill == nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{
"code": http.StatusNotFound,
"message": "no bill matches that reference at this outlet",
"status": false,
})
}
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": bill})
}
// GetSalesSummary totals a range, split by tender, day and till.
func (ctl *PosController) GetSalesSummary(c *fiber.Ctx) error {
filter, err := posSalesFilter(c)
if err != nil {
return posBadRequest(c, err)
}
summary, err := ctl.posService.SalesSummary(filter)
if err != nil {
return posServerError(c, "GetSalesSummary", err)
}
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": summary})
}
func posBadRequest(c *fiber.Ctx, err error) error {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": err.Error(), "status": false,
})
}
func posServerError(c *fiber.Ctx, op string, err error) error {
log.Printf("pos %s: %v", op, err)
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"code": http.StatusInternalServerError, "message": err.Error(), "status": false,
})
}
// posIngestError decides whether the terminal should retry.
//
// The distinction matters more than the message does. A misconfigured store id
// will be just as wrong on the next attempt, so it is reported as a 4xx and the
// till halts and shows a person the reason. Anything else might succeed later,
// so it is a 5xx and the bills stay queued.
func posIngestError(c *fiber.Ctx, op string, err error) error {
log.Printf("pos %s: %v", op, err)
message := err.Error()
lower := strings.ToLower(message)
permanent := strings.Contains(lower, "is not a location id") ||
strings.Contains(lower, "no outlet is registered") ||
strings.Contains(lower, "does not belong to tenant") ||
strings.Contains(lower, "has no products stocked") ||
strings.Contains(lower, "no applocationid configured")
status := http.StatusInternalServerError
if permanent {
status = http.StatusBadRequest
}
return c.Status(status).JSON(fiber.Map{
"code": status,
"message": message,
"status": false,
})
}