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, }) }