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>
224 lines
6.6 KiB
Go
224 lines
6.6 KiB
Go
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
|
|
}
|