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