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>
This commit is contained in:
Suriya
2026-08-05 18:28:38 +05:30
parent bddd8fa265
commit ec672a3087
4 changed files with 122 additions and 2 deletions

View File

@@ -95,6 +95,63 @@ func (ctl *PosController) IngestCustomers(c *fiber.Ctx) error {
return c.Status(http.StatusOK).JSON(ack) 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. // Catalogue answers a terminal's product pull.
func (ctl *PosController) Catalogue(c *fiber.Ctx) error { func (ctl *PosController) Catalogue(c *fiber.Ctx) error {
storeID := strings.TrimSpace(c.Query("store_id")) storeID := strings.TrimSpace(c.Query("store_id"))

View File

@@ -112,7 +112,7 @@ func (r *posRepository) IngestOrders(batch models.PosOrderBatch) (*models.PosAck
ack.Reject("", "order is missing its id") ack.Reject("", "order is missing its id")
continue continue
} }
if reason := r.importPosOrder(ctx, products, batch.Batchid, order); reason != "" { if reason := r.importPosOrder(ctx, products, batch.Batchid, batch.Terminalid, order); reason != "" {
ack.Reject(order.Id, reason) ack.Reject(order.Id, reason)
continue continue
} }
@@ -132,10 +132,15 @@ func (r *posRepository) IngestOrders(batch models.PosOrderBatch) (*models.PosAck
// in opposite directions and both matter: the bill is its own kind of document // in opposite directions and both matter: the bill is its own kind of document
// and deserves its own table, but stock is one number per shelf and must not be // and deserves its own table, but stock is one number per shelf and must not be
// tracked twice. // tracked twice.
// batchTerminal is the terminal the whole batch came from, used when a bill
// does not name one itself. Over MQTT the consumer fills it in from the topic;
// over HTTP the terminal sends it once at the top of the batch rather than
// repeating it on every bill.
func (r *posRepository) importPosOrder( func (r *posRepository) importPosOrder(
ctx *offlineLocationContext, ctx *offlineLocationContext,
products map[int]offlineProduct, products map[int]offlineProduct,
batchID string, batchID string,
batchTerminal string,
order models.PosOrder, order models.PosOrder,
) string { ) string {
if len(order.Items) == 0 { if len(order.Items) == 0 {
@@ -302,7 +307,11 @@ func (r *posRepository) importPosOrder(
Invoicenumber: order.Invoicenumber, Invoicenumber: order.Invoicenumber,
Tenantid: ctx.Tenantid, Tenantid: ctx.Tenantid,
Locationid: ctx.Locationid, Locationid: ctx.Locationid,
Terminalid: order.Terminalid, // The bill's own terminal wins; the batch's is the fallback. Without
// this the column was empty on every bill that arrived over HTTP —
// the invoice number carried the code and the column did not, so
// per-terminal reconciliation had nothing to group on.
Terminalid: posTerminalFor(order.Terminalid, batchTerminal),
Cashiername: order.Cashier, Cashiername: order.Cashier,
Customerid: customerID, Customerid: customerID,
Customermobile: posCustomerMobile(order), Customermobile: posCustomerMobile(order),
@@ -372,6 +381,18 @@ func posJSON(v any) string {
return string(body) return string(body)
} }
// posTerminalFor picks which terminal code to file a bill under.
//
// Trimmed before the emptiness test: a terminal sending `" "` is saying nothing,
// and treating that as a real code would file bills under a blank that looks
// identical to the missing value this exists to fix.
func posTerminalFor(orderTerminal, batchTerminal string) string {
if t := strings.TrimSpace(orderTerminal); t != "" {
return t
}
return strings.TrimSpace(batchTerminal)
}
func posCustomerName(order models.PosOrder) string { func posCustomerName(order models.PosOrder) string {
if order.Customer == nil { if order.Customer == nil {
return "" return ""

View File

@@ -160,3 +160,40 @@ func TestAnOutletCannotReplayAnotherOutletsRevision(t *testing.T) {
} }
} }
} }
// The bug this covers reached production and stayed invisible for a day.
//
// The MQTT consumer backfills a missing terminal code from the topic, but it
// wrote it onto the *batch* while the row was built from the *order*, so the
// two never met. Bills arriving over HTTP had no topic to fall back on at all.
// The result: 16 of 17 live bills carried an empty terminalid while their own
// invoice numbers read INV-2608-T5EDD-000NN, and `byterminal` on the sales
// summary grouped almost everything under "".
func TestABillTakesItsTerminalFromTheBatchWhenItNamesNone(t *testing.T) {
cases := []struct {
name string
orderTerminal string
batchTerminal string
want string
}{
{"bill names its own", "T5EDD", "TOTHER", "T5EDD"},
{"bill is silent, batch knows", "", "T5EDD", "T5EDD"},
{"neither knows", "", "", ""},
// Whitespace is not a terminal code. Treating it as one would file
// bills under a blank that reads identically to the missing value
// this fallback exists to prevent.
{"bill sends whitespace", " ", "T5EDD", "T5EDD"},
{"batch sends whitespace", "", " ", ""},
{"codes are trimmed", " T5EDD ", "", "T5EDD"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := posTerminalFor(c.orderTerminal, c.batchTerminal); got != c.want {
t.Errorf("posTerminalFor(%q, %q) = %q, want %q",
c.orderTerminal, c.batchTerminal, got, c.want)
}
})
}
}

View File

@@ -23,6 +23,11 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
pos.Post("/customers", f.PosController.IngestCustomers) pos.Post("/customers", f.PosController.IngestCustomers)
pos.Get("/catalogue", f.PosController.Catalogue) pos.Get("/catalogue", f.PosController.Catalogue)
// The 30-second heartbeat, for tills on the HTTP route. The broker carries
// the same payload for tills on MQTT; both land in the same Redis record,
// so the fleet board cannot tell them apart and does not need to.
pos.Post("/health", f.PosController.IngestHealth)
// Counter sales, read back out. The ingest above only ever writes; without // Counter sales, read back out. The ingest above only ever writes; without
// these a committed bill is unreachable from every screen in the product. // these a committed bill is unreachable from every screen in the product.
pos.Get("/sales", f.PosController.GetSales) pos.Get("/sales", f.PosController.GetSales)