diff --git a/POS_TERMINAL_INGEST.md b/POS_TERMINAL_INGEST.md index 80a90f1..81f93dd 100644 --- a/POS_TERMINAL_INGEST.md +++ b/POS_TERMINAL_INGEST.md @@ -52,11 +52,41 @@ the standing cost of the split. ### HTTP +Base path: `/live/api/v1/pos` + +**Written by a terminal** — bare-ack responses, see below. + | Method | Path | Purpose | |---|---|---| -| `POST` | `/live/api/v1/pos/orders` | Completed bills | -| `POST` | `/live/api/v1/pos/customers` | Shoppers registered at a till | -| `GET` | `/live/api/v1/pos/catalogue` | Product pull. Query: `store_id`, `since`, `page`, `page_size` | +| `POST` | `/orders` | Completed bills | +| `POST` | `/customers` | Shoppers registered at a till | +| `GET` | `/catalogue` | Product pull. Query: `store_id`, `since`, `page`, `page_size` | + +**Read by the web app** — normal `{code, message, status, details}` envelope. + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/sales` | Bills for an outlet, newest first | +| `GET` | `/sales/detail` | One bill with its lines | +| `GET` | `/sales/summary` | Totals by tender, day and till | +| `GET` | `/health/terminal` | One till's live state | +| `GET` | `/health/location` | Every till at a shop | + +`/sales` and `/sales/summary` take: **`locationid` (required)**, `fromdate`, +`todate` (YYYY-MM-DD, matched on `businessdate`), `terminalid`, `cashiername`, +`paymentmode`, `pageno`, `pagesize`. + +`/sales/detail` takes `locationid` and `reference` — the terminal's order UUID, +the invoice number, or the `posorderid`, whichever the caller happens to have. + +**`locationid` is the authorisation boundary.** Every read is scoped to one +outlet; omitting it is an error rather than a page through every shop's takings, +and asking for a bill under the wrong outlet returns 404 even when the reference +is valid. + +Dates match `businessdate` — the day the sale was rung, not the day it reached +us. A till that was offline overnight uploads yesterday's bills this morning and +they belong to yesterday. These answer with a **bare ack**, not the usual `{code, message, status}` envelope — the terminal reads `accepted` from the top level of the body: diff --git a/controllers/posController.go b/controllers/posController.go index 86031ea..8dbf992 100644 --- a/controllers/posController.go +++ b/controllers/posController.go @@ -1,6 +1,7 @@ package controllers import ( + "fmt" "log" "net/http" "strconv" @@ -188,6 +189,107 @@ func (ctl *PosController) LocationHealth(c *fiber.Ctx) error { }) } +// ---------------------------------------------------------------- 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 diff --git a/messaging/posmqtt_test.go b/messaging/posmqtt_test.go index 82d0670..f3a40ea 100644 --- a/messaging/posmqtt_test.go +++ b/messaging/posmqtt_test.go @@ -56,6 +56,18 @@ func (f *fakePosService) TerminalHealth(context.Context, string) (map[string]str return nil, nil } +func (f *fakePosService) Sales(models.PosSalesFilter) (*models.PosSalesPage, error) { + return nil, nil +} + +func (f *fakePosService) SaleDetail(int, string) (*models.PosOrders, error) { + return nil, nil +} + +func (f *fakePosService) SalesSummary(models.PosSalesFilter) (*models.PosSalesSummary, error) { + return nil, nil +} + func (f *fakePosService) LocationHealth(context.Context, string) ([]map[string]string, error) { return nil, nil } @@ -100,9 +112,9 @@ func (c *fakeClient) Subscribe(string, byte, mqtt.MessageHandler) mqtt.Token { func (c *fakeClient) SubscribeMultiple(map[string]byte, mqtt.MessageHandler) mqtt.Token { return doneToken{} } -func (c *fakeClient) Unsubscribe(...string) mqtt.Token { return doneToken{} } -func (c *fakeClient) AddRoute(string, mqtt.MessageHandler) {} -func (c *fakeClient) OptionsReader() mqtt.ClientOptionsReader { return mqtt.ClientOptionsReader{} } +func (c *fakeClient) Unsubscribe(...string) mqtt.Token { return doneToken{} } +func (c *fakeClient) AddRoute(string, mqtt.MessageHandler) {} +func (c *fakeClient) OptionsReader() mqtt.ClientOptionsReader { return mqtt.ClientOptionsReader{} } type doneToken struct{} diff --git a/models/posorder.go b/models/posorder.go index 33c85a5..7a744bd 100644 --- a/models/posorder.go +++ b/models/posorder.go @@ -136,3 +136,74 @@ type PosOrderItems struct { func (PosOrderItems) TableName() string { return "pos_order_items" } + +// PosSalesFilter scopes a query over counter sales. +// +// Locationid is required and is the authorisation boundary — every read is +// scoped to one outlet, so a caller cannot page through another shop's takings +// by omitting a parameter. +type PosSalesFilter struct { + Locationid int + Fromdate string // YYYY-MM-DD, matched against businessdate + Todate string + Terminalid string + Cashiername string + Paymentmode string + Pageno int + Pagesize int +} + +// PosSalesPage is one page of bills, with the total so a caller can paginate +// without a second request. +type PosSalesPage struct { + Total int64 `json:"total"` + Pageno int `json:"pageno"` + Pagesize int `json:"pagesize"` + Bills []PosOrders `json:"bills"` +} + +// PosSalesSummary totals a range of counter sales. +// +// Deliberately separate from the bill list: a shop settling a till wants the +// figures, not five hundred rows, and computing them client-side would mean +// fetching every page first. +type PosSalesSummary struct { + Locationid int `json:"locationid"` + Fromdate string `json:"fromdate"` + Todate string `json:"todate"` + Billcount int `json:"billcount"` + Itemcount int `json:"itemcount"` + Grosssales float64 `json:"grosssales"` + Taxcollected float64 `json:"taxcollected"` + Discount float64 `json:"discountgiven"` + Roundoff float64 `json:"roundoff"` + Averagebill float64 `json:"averagebill"` + + // What a cashier reconciles the drawer against. + Bypaymentmode []PosPaymentTotal `json:"bypaymentmode"` + + // One row per trading day, for a chart. + Byday []PosDayTotal `json:"byday"` + + // Which tills contributed, so an outlet with several counters can see them + // apart without a second query. + Byterminal []PosTerminalTotal `json:"byterminal"` +} + +type PosPaymentTotal struct { + Paymentmode string `json:"paymentmode"` + Billcount int `json:"billcount"` + Amount float64 `json:"amount"` +} + +type PosDayTotal struct { + Businessdate string `json:"businessdate"` + Billcount int `json:"billcount"` + Amount float64 `json:"amount"` +} + +type PosTerminalTotal struct { + Terminalid string `json:"terminalid"` + Billcount int `json:"billcount"` + Amount float64 `json:"amount"` +} diff --git a/repositories/posRepository.go b/repositories/posRepository.go index 3980d7c..5bba429 100644 --- a/repositories/posRepository.go +++ b/repositories/posRepository.go @@ -37,6 +37,12 @@ type PosRepository interface { IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error) IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) + + // Reading counter sales back out. Without these a committed bill is + // unreachable from every screen in the product. + Sales(f models.PosSalesFilter) (*models.PosSalesPage, error) + SaleDetail(locationID int, reference string) (*models.PosOrders, error) + SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error) } type posRepository struct { diff --git a/repositories/posSalesRepository.go b/repositories/posSalesRepository.go new file mode 100644 index 0000000..ae8219a --- /dev/null +++ b/repositories/posSalesRepository.go @@ -0,0 +1,223 @@ +package repositories + +import ( + "fmt" + "strings" + + "nearle/models" +) + +// Reading counter sales back out. +// +// The ingest side of this package only ever writes. Without these, a bill that +// reached pos_orders was invisible to every screen in the product — the data +// was safe and unreachable, which is its own kind of lost. +// +// Every query is scoped to one locationid. That is the authorisation boundary: +// a caller who omits it gets an error rather than a page through somebody +// else's takings. + +// posSalesWhere builds the shared filter, so the list, the detail and the +// summary can never disagree about what "this outlet in this range" means. +func posSalesWhere(f models.PosSalesFilter) (string, []interface{}) { + where := "locationid = ?" + params := []interface{}{f.Locationid} + + // Matched on businessdate — the day the sale was rung, not the day it + // reached us. A till that was offline overnight uploads yesterday's bills + // this morning and they belong to yesterday. + if f.Fromdate != "" && f.Todate != "" { + where += " AND businessdate BETWEEN ? AND ?" + params = append(params, f.Fromdate, f.Todate) + } else if f.Fromdate != "" { + where += " AND businessdate >= ?" + params = append(params, f.Fromdate) + } else if f.Todate != "" { + where += " AND businessdate <= ?" + params = append(params, f.Todate) + } + + if t := strings.TrimSpace(f.Terminalid); t != "" { + where += " AND terminalid = ?" + params = append(params, t) + } + if c := strings.TrimSpace(f.Cashiername); c != "" { + where += " AND cashiername = ?" + params = append(params, c) + } + if p := strings.TrimSpace(f.Paymentmode); p != "" { + where += " AND LOWER(paymentmode) = ?" + params = append(params, strings.ToLower(p)) + } + + return where, params +} + +// Sales returns a page of bills, newest first, with the total count. +// +// Line items are deliberately not included: a page of fifty bills would drag +// several hundred rows behind it, and a list screen shows none of them. Use +// SaleDetail for one bill. +func (r *posRepository) Sales(f models.PosSalesFilter) (*models.PosSalesPage, error) { + if f.Locationid <= 0 { + return nil, fmt.Errorf("locationid is required") + } + + if f.Pagesize <= 0 || f.Pagesize > 500 { + f.Pagesize = 50 + } + if f.Pageno < 0 { + f.Pageno = 0 + } + + where, params := posSalesWhere(f) + + var total int64 + if err := r.db.Raw( + fmt.Sprintf(`SELECT COUNT(*) FROM pos_orders WHERE %s`, where), + params..., + ).Scan(&total).Error; err != nil { + return nil, err + } + + bills := make([]models.PosOrders, 0) + // Ordered by billedat rather than by id: a batch uploaded after an outage + // arrives out of order, and a list sorted by arrival would interleave + // yesterday's bills through today's. + query := fmt.Sprintf( + `SELECT * FROM pos_orders WHERE %s + ORDER BY billedat DESC, posorderid DESC + LIMIT ? OFFSET ?`, where) + + if err := r.db.Raw(query, + append(params, f.Pagesize, f.Pageno*f.Pagesize)..., + ).Scan(&bills).Error; err != nil { + return nil, err + } + + return &models.PosSalesPage{ + Total: total, + Pageno: f.Pageno, + Pagesize: f.Pagesize, + Bills: bills, + }, nil +} + +// SaleDetail returns one bill with its lines. +// +// Accepts either the terminal's own order UUID or this backend's posorderid, +// because a support call starts from whichever the caller happens to be looking +// at — a receipt carries the invoice number, a log carries the UUID. +func (r *posRepository) SaleDetail(locationID int, reference string) (*models.PosOrders, error) { + if locationID <= 0 { + return nil, fmt.Errorf("locationid is required") + } + reference = strings.TrimSpace(reference) + if reference == "" { + return nil, fmt.Errorf("an order id, invoice number or posorderid is required") + } + + var bill models.PosOrders + err := r.db.Raw(` + SELECT * FROM pos_orders + WHERE locationid = ? + AND (terminalorderid = ? OR invoicenumber = ? + OR CAST(posorderid AS TEXT) = ?) + LIMIT 1`, + locationID, reference, reference, reference, + ).Scan(&bill).Error + if err != nil { + return nil, err + } + if bill.Posorderid == 0 { + return nil, nil + } + + items := make([]models.PosOrderItems, 0) + if err := r.db.Raw( + `SELECT * FROM pos_order_items WHERE posorderid = ? ORDER BY posorderitemid`, + bill.Posorderid, + ).Scan(&items).Error; err != nil { + return nil, err + } + bill.Items = items + + return &bill, nil +} + +// SalesSummary totals a range, broken out the three ways somebody actually +// asks for: by tender, by day, and by till. +func (r *posRepository) SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error) { + if f.Locationid <= 0 { + return nil, fmt.Errorf("locationid is required") + } + + where, params := posSalesWhere(f) + + summary := &models.PosSalesSummary{ + Locationid: f.Locationid, + Fromdate: f.Fromdate, + Todate: f.Todate, + Bypaymentmode: make([]models.PosPaymentTotal, 0), + Byday: make([]models.PosDayTotal, 0), + Byterminal: make([]models.PosTerminalTotal, 0), + } + + var head struct { + Billcount int + Itemcount int + Grosssales float64 + Taxcollected float64 + Discount float64 + Roundoff float64 + } + if err := r.db.Raw(fmt.Sprintf(` + SELECT COUNT(*) AS billcount, + COALESCE(SUM(itemcount), 0) AS itemcount, + COALESCE(SUM(total), 0) AS grosssales, + COALESCE(SUM(taxamount), 0) AS taxcollected, + COALESCE(SUM(discount), 0) AS discount, + COALESCE(SUM(roundoff), 0) AS roundoff + FROM pos_orders WHERE %s`, where), params...).Scan(&head).Error; err != nil { + return nil, err + } + + summary.Billcount = head.Billcount + summary.Itemcount = head.Itemcount + summary.Grosssales = head.Grosssales + summary.Taxcollected = head.Taxcollected + summary.Discount = head.Discount + summary.Roundoff = head.Roundoff + if head.Billcount > 0 { + summary.Averagebill = head.Grosssales / float64(head.Billcount) + } + + if err := r.db.Raw(fmt.Sprintf(` + SELECT COALESCE(paymentmode,'') AS paymentmode, + COUNT(*) AS billcount, COALESCE(SUM(total),0) AS amount + FROM pos_orders WHERE %s + GROUP BY paymentmode ORDER BY amount DESC`, where), + params...).Scan(&summary.Bypaymentmode).Error; err != nil { + return nil, err + } + + if err := r.db.Raw(fmt.Sprintf(` + SELECT businessdate, COUNT(*) AS billcount, + COALESCE(SUM(total),0) AS amount + FROM pos_orders WHERE %s + GROUP BY businessdate ORDER BY businessdate`, where), + params...).Scan(&summary.Byday).Error; err != nil { + return nil, err + } + + if err := r.db.Raw(fmt.Sprintf(` + SELECT COALESCE(terminalid,'') AS terminalid, COUNT(*) AS billcount, + COALESCE(SUM(total),0) AS amount + FROM pos_orders WHERE %s + GROUP BY terminalid ORDER BY amount DESC`, where), + params...).Scan(&summary.Byterminal).Error; err != nil { + return nil, err + } + + return summary, nil +} diff --git a/routes/posroutes.go b/routes/posroutes.go index 66ee3ac..a5e700a 100644 --- a/routes/posroutes.go +++ b/routes/posroutes.go @@ -23,6 +23,12 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) { pos.Post("/customers", f.PosController.IngestCustomers) pos.Get("/catalogue", f.PosController.Catalogue) + // 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) + pos.Get("/sales/detail", f.PosController.GetSaleDetail) + pos.Get("/sales/summary", f.PosController.GetSalesSummary) + // Terminal presence, read from Redis. What the rider app's POS board and a // support call both hit — the tills themselves publish health over the // broker rather than posting it here. diff --git a/services/posService.go b/services/posService.go index 04534b6..eb40884 100644 --- a/services/posService.go +++ b/services/posService.go @@ -19,6 +19,10 @@ type PosService interface { TerminalHealth(ctx context.Context, terminalID string) (map[string]string, error) LocationHealth(ctx context.Context, locationID string) ([]map[string]string, error) + + Sales(f models.PosSalesFilter) (*models.PosSalesPage, error) + SaleDetail(locationID int, reference string) (*models.PosOrders, error) + SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error) } type posService struct { @@ -53,3 +57,15 @@ func (s *posService) IngestCustomers(batch models.PosCustomerBatch) (*models.Pos func (s *posService) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) { return s.repo.Catalogue(storeID, since, page, pageSize) } + +func (s *posService) Sales(f models.PosSalesFilter) (*models.PosSalesPage, error) { + return s.repo.Sales(f) +} + +func (s *posService) SaleDetail(locationID int, reference string) (*models.PosOrders, error) { + return s.repo.SaleDetail(locationID, reference) +} + +func (s *posService) SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error) { + return s.repo.SalesSummary(f) +}