diff --git a/controllers/posController.go b/controllers/posController.go index 8dbf992..7b7b8b0 100644 --- a/controllers/posController.go +++ b/controllers/posController.go @@ -95,6 +95,63 @@ func (ctl *PosController) IngestCustomers(c *fiber.Ctx) error { 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")) diff --git a/repositories/posRepository.go b/repositories/posRepository.go index a23f992..5d153ad 100644 --- a/repositories/posRepository.go +++ b/repositories/posRepository.go @@ -112,7 +112,7 @@ func (r *posRepository) IngestOrders(batch models.PosOrderBatch) (*models.PosAck ack.Reject("", "order is missing its id") 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) 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 // and deserves its own table, but stock is one number per shelf and must not be // 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( ctx *offlineLocationContext, products map[int]offlineProduct, batchID string, + batchTerminal string, order models.PosOrder, ) string { if len(order.Items) == 0 { @@ -302,7 +307,11 @@ func (r *posRepository) importPosOrder( Invoicenumber: order.Invoicenumber, Tenantid: ctx.Tenantid, 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, Customerid: customerID, Customermobile: posCustomerMobile(order), @@ -372,6 +381,18 @@ func posJSON(v any) string { 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 { if order.Customer == nil { return "" diff --git a/repositories/posRepository_test.go b/repositories/posRepository_test.go index f11a5cf..5905704 100644 --- a/repositories/posRepository_test.go +++ b/repositories/posRepository_test.go @@ -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) + } + }) + } +} diff --git a/routes/posroutes.go b/routes/posroutes.go index a5e700a..49e713b 100644 --- a/routes/posroutes.go +++ b/routes/posroutes.go @@ -23,6 +23,11 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) { pos.Post("/customers", f.PosController.IngestCustomers) 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 // these a committed bill is unreachable from every screen in the product. pos.Get("/sales", f.PosController.GetSales)