Add read endpoints for counter sales
The ingest only ever wrote. A bill that reached pos_orders was safe and completely unreachable — no screen in the product could show it, and the only way to see a day's counter takings was to query the database by hand. Three endpoints: a paged bill list, one bill with its lines, and a summary split the three ways somebody actually asks for — by tender for reconciling a drawer, by day for a chart, by till for an outlet running several counters. locationid is required on all of them and is the authorisation boundary, so a caller cannot page through another shop's takings by omitting a parameter. Fetching a bill under the wrong outlet returns 404 even when the reference is a real one. Dates match businessdate rather than arrival, because a till that was offline overnight uploads yesterday's bills this morning and they belong to yesterday. The list is ordered by billedat for the same reason — sorting by arrival would interleave a recovered backlog through today. Unlike the ingest handlers these answer in the usual envelope: they are read by the web app, not by a terminal, and nothing about them is bound to the till's contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -52,11 +52,41 @@ the standing cost of the split.
|
|||||||
|
|
||||||
### HTTP
|
### HTTP
|
||||||
|
|
||||||
|
Base path: `/live/api/v1/pos`
|
||||||
|
|
||||||
|
**Written by a terminal** — bare-ack responses, see below.
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `POST` | `/live/api/v1/pos/orders` | Completed bills |
|
| `POST` | `/orders` | Completed bills |
|
||||||
| `POST` | `/live/api/v1/pos/customers` | Shoppers registered at a till |
|
| `POST` | `/customers` | Shoppers registered at a till |
|
||||||
| `GET` | `/live/api/v1/pos/catalogue` | Product pull. Query: `store_id`, `since`, `page`, `page_size` |
|
| `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}`
|
These answer with a **bare ack**, not the usual `{code, message, status}`
|
||||||
envelope — the terminal reads `accepted` from the top level of the body:
|
envelope — the terminal reads `accepted` from the top level of the body:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"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.
|
// posIngestError decides whether the terminal should retry.
|
||||||
//
|
//
|
||||||
// The distinction matters more than the message does. A misconfigured store id
|
// The distinction matters more than the message does. A misconfigured store id
|
||||||
|
|||||||
@@ -56,6 +56,18 @@ func (f *fakePosService) TerminalHealth(context.Context, string) (map[string]str
|
|||||||
return nil, nil
|
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) {
|
func (f *fakePosService) LocationHealth(context.Context, string) ([]map[string]string, error) {
|
||||||
return nil, nil
|
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 {
|
func (c *fakeClient) SubscribeMultiple(map[string]byte, mqtt.MessageHandler) mqtt.Token {
|
||||||
return doneToken{}
|
return doneToken{}
|
||||||
}
|
}
|
||||||
func (c *fakeClient) Unsubscribe(...string) mqtt.Token { return doneToken{} }
|
func (c *fakeClient) Unsubscribe(...string) mqtt.Token { return doneToken{} }
|
||||||
func (c *fakeClient) AddRoute(string, mqtt.MessageHandler) {}
|
func (c *fakeClient) AddRoute(string, mqtt.MessageHandler) {}
|
||||||
func (c *fakeClient) OptionsReader() mqtt.ClientOptionsReader { return mqtt.ClientOptionsReader{} }
|
func (c *fakeClient) OptionsReader() mqtt.ClientOptionsReader { return mqtt.ClientOptionsReader{} }
|
||||||
|
|
||||||
type doneToken struct{}
|
type doneToken struct{}
|
||||||
|
|
||||||
|
|||||||
@@ -136,3 +136,74 @@ type PosOrderItems struct {
|
|||||||
func (PosOrderItems) TableName() string {
|
func (PosOrderItems) TableName() string {
|
||||||
return "pos_order_items"
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ type PosRepository interface {
|
|||||||
IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error)
|
IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error)
|
||||||
IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error)
|
IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error)
|
||||||
Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, 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 {
|
type posRepository struct {
|
||||||
|
|||||||
223
repositories/posSalesRepository.go
Normal file
223
repositories/posSalesRepository.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -23,6 +23,12 @@ 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)
|
||||||
|
|
||||||
|
// 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
|
// 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
|
// support call both hit — the tills themselves publish health over the
|
||||||
// broker rather than posting it here.
|
// broker rather than posting it here.
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ type PosService interface {
|
|||||||
|
|
||||||
TerminalHealth(ctx context.Context, terminalID string) (map[string]string, error)
|
TerminalHealth(ctx context.Context, terminalID string) (map[string]string, error)
|
||||||
LocationHealth(ctx context.Context, locationID 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 {
|
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) {
|
func (s *posService) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) {
|
||||||
return s.repo.Catalogue(storeID, since, page, pageSize)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user