Files
backend_fiesta/controllers/posController.go
Suriya 8709556704 Add read endpoints for counter sales
The ingest only ever wrote. A bill that reached pos_orders was safe and
completely unreachable — no screen in the product could show it, and the
only way to see a day's counter takings was to query the database by
hand.

Three endpoints: a paged bill list, one bill with its lines, and a
summary split the three ways somebody actually asks for — by tender for
reconciling a drawer, by day for a chart, by till for an outlet running
several counters.

locationid is required on all of them and is the authorisation boundary,
so a caller cannot page through another shop's takings by omitting a
parameter. Fetching a bill under the wrong outlet returns 404 even when
the reference is a real one.

Dates match businessdate rather than arrival, because a till that was
offline overnight uploads yesterday's bills this morning and they belong
to yesterday. The list is ordered by billedat for the same reason —
sorting by arrival would interleave a recovered backlog through today.

Unlike the ingest handlers these answer in the usual envelope: they are
read by the web app, not by a terminal, and nothing about them is bound
to the till's contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:17:03 +05:30

322 lines
10 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)
}
// 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,
})
}