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