Ingest counter sales from the POS terminals, over MQTT and HTTP

A till holds every bill in its own SQLite database and keeps it for
seven days after we acknowledge it, marking one synced only when its id
comes back in an ack. Everything here follows from that.

Silence is not acceptance, so a failing ingest publishes nothing at all
and the terminal simply sends again. A duplicate is a success, because
at-least-once delivery means a lost ack legitimately re-delivers bills
we already hold, and calling those failures would strand a day of
takings on the till. Deduplication is a unique index on the terminal's
UUID plus an advisory lock held for the transaction.

Bills land in pos_orders / pos_order_items rather than orders: a counter
bill carries a cashier, a terminal, a rounding adjustment, promos,
loyalty movement and a payment split that orders has nowhere to put, and
forcing one into the other loses whatever does not fit. Stock is *not*
split — a counter sale writes the same productstocks rows an app order
does, through helpers extracted from createOrderTx so the rule that
prevents overselling has one implementation rather than two.
GetRevenueSummary and GetSalesSummary were extended to union the new
table in; any new report has to remember the same.

Terminal health goes to Redis under a 90-second TTL, sharing the
instance the express backend uses. A heartbeat is a fact with an expiry
date: a till that loses power stops refreshing and ages off the board by
itself, where a Postgres row would need ~288k writes a day and a reaper.

Proven end to end against the live estate before commit: a bill over
HTTP and one over the real Mosquitto broker, the same bill three times
producing one row and one stock movement, and a heartbeat arriving on
the health endpoint. All probe data was removed afterwards.

Four things that only surfaced against real data. An unset jsonb column
failed the very first bill. Product SKUs are unusable as barcodes — 6,245
products share 93 SKUs and "1" covers 5,794 of them — against the till's
unique index, so barcodes fall back to the product id. A taxpercent of
-1 exists and would have put negative GST in a filed slab. And a product
with id 0 exists, which can never be billed and is now skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-08-03 17:48:00 +05:30
parent 583cd89063
commit e3459a0f1c
23 changed files with 3679 additions and 171 deletions

View File

@@ -748,7 +748,17 @@ func (r *orderRepository) GetRevenueSummary(tid, lid int, fdate, tdate string) (
if err := r.db.Raw(overallQuery, overallParams...).Scan(&overallRev).Error; err != nil {
return nil, err
}
summary.OverallRevenue = overallRev
// Counter sales live in their own table, so every figure that reads
// `orders` alone understates a shop that runs a till. Added here rather
// than by rewriting the query above: the join and the dynamic parameters
// are load-bearing for app orders and not worth disturbing.
posTotal, posByLocation, err := r.posRevenue(tid, lid, fdate, tdate)
if err != nil {
return nil, err
}
summary.OverallRevenue = overallRev + posTotal
// 4. Fetch revenue details by location
locationQuery := `
@@ -783,15 +793,163 @@ func (r *orderRepository) GetRevenueSummary(tid, lid int, fdate, tdate string) (
if err := r.db.Raw(locationQuery, locParams...).Scan(&locRevenues).Error; err != nil {
return nil, err
}
if locRevenues == nil {
locRevenues = []models.LocationRevenueDetails{}
}
// The location list comes from tenantlocations, so an outlet that trades
// only through its counter still has a row here — it just has zero app
// revenue against it. Adding rather than replacing keeps both visible.
for i := range locRevenues {
locRevenues[i].Revenue += posByLocation[locRevenues[i].Locationid]
}
summary.LocationRevenue = locRevenues
return &summary, nil
}
// posSalesTotals returns counter-sale revenue and bill count, overall and by
// day, scoped exactly as GetSalesSummary scopes app orders.
func (r *orderRepository) posSalesTotals(tid, lid int, fdate, tdate string) (
struct {
Revenue float64
Orders int
},
[]models.SalesSummaryChartData,
error,
) {
var totals struct {
Revenue float64
Orders int
}
where := "tenantid = ?"
params := []interface{}{tid}
if lid != 0 {
where += " AND locationid = ?"
params = append(params, lid)
}
if fdate != "" && tdate != "" {
where += " AND businessdate BETWEEN ? AND ?"
params = append(params, fdate, tdate)
}
totalsQuery := fmt.Sprintf(
`SELECT COALESCE(SUM(total), 0) AS revenue, COUNT(posorderid) AS orders
FROM pos_orders WHERE %s`, where)
if err := r.db.Raw(totalsQuery, params...).Scan(&totals).Error; err != nil {
return totals, nil, err
}
dailyQuery := fmt.Sprintf(
`SELECT businessdate AS date, COALESCE(SUM(total), 0) AS revenue,
COUNT(posorderid) AS orders
FROM pos_orders WHERE %s
GROUP BY businessdate ORDER BY businessdate ASC`, where)
var daily []models.SalesSummaryChartData
if err := r.db.Raw(dailyQuery, params...).Scan(&daily).Error; err != nil {
return totals, nil, err
}
return totals, daily, nil
}
// mergePosIntoChart folds counter sales into the app-order series by date.
//
// A day present in one and not the other has to appear rather than be dropped:
// a shop that sells only over the counter has no app orders at all, and an
// inner join on date would show it an empty chart.
func mergePosIntoChart(
app []models.SalesSummaryChartData,
pos []models.SalesSummaryChartData,
) []models.SalesSummaryChartData {
if len(pos) == 0 {
return app
}
// Dates arrive in two shapes — the app series casts a timestamp, the POS
// series stores a plain YYYY-MM-DD string — so both are trimmed to ten
// characters before being matched, or every day would appear twice.
dayOf := func(s string) string {
if len(s) >= 10 {
return s[:10]
}
return s
}
index := make(map[string]int, len(app))
merged := make([]models.SalesSummaryChartData, 0, len(app)+len(pos))
for _, row := range app {
index[dayOf(row.Date)] = len(merged)
merged = append(merged, row)
}
for _, row := range pos {
day := dayOf(row.Date)
if at, ok := index[day]; ok {
merged[at].Revenue += row.Revenue
merged[at].Orders += row.Orders
continue
}
index[day] = len(merged)
merged = append(merged, models.SalesSummaryChartData{
Date: day,
Revenue: row.Revenue,
Orders: row.Orders,
})
}
sort.Slice(merged, func(a, b int) bool {
return dayOf(merged[a].Date) < dayOf(merged[b].Date)
})
return merged
}
// posRevenue totals counter sales, overall and per location.
//
// Scoped the same way GetRevenueSummary scopes app orders — tenant, optional
// location, optional date range — so the two halves of a figure always cover
// the same ground. Dates match on businessdate, which is the day the sale was
// rung rather than the day it reached us: a till that was offline overnight
// uploads yesterday's bills this morning, and they belong to yesterday.
func (r *orderRepository) posRevenue(tid, lid int, fdate, tdate string) (float64, map[int]float64, error) {
query := `SELECT locationid, COALESCE(SUM(total), 0) AS revenue
FROM pos_orders WHERE tenantid = ?`
params := []interface{}{tid}
if lid != 0 {
query += " AND locationid = ?"
params = append(params, lid)
}
if fdate != "" && tdate != "" {
query += " AND businessdate BETWEEN ? AND ?"
params = append(params, fdate, tdate)
}
query += " GROUP BY locationid"
var rows []struct {
Locationid int
Revenue float64
}
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
return 0, nil, err
}
total := 0.0
byLocation := make(map[int]float64, len(rows))
for _, row := range rows {
total += row.Revenue
byLocation[row.Locationid] = row.Revenue
}
return total, byLocation, nil
}
func (r *orderRepository) GetDistinctLocations() ([]models.OrderInsight, error) {
var locations []models.OrderInsight
@@ -809,44 +967,52 @@ func (r *orderRepository) GetDistinctLocations() ([]models.OrderInsight, error)
func (r *orderRepository) GetSalesSummary(tid, lid int, fdate, tdate string) (*models.SalesSummaryResponse, error) {
var summary models.SalesSummaryResponse
whereClause := "tenantid = ? AND orderstatus IN ('delivered', 'completed') AND configid = 1"
var params []interface{}
params = append(params, tid)
if lid != 0 {
whereClause += " AND locationid = ?"
params = append(params, lid)
}
if fdate != "" && tdate != "" {
whereClause += " AND orderdate::date BETWEEN ? AND ?"
params = append(params, fdate, tdate)
}
totalsQuery := fmt.Sprintf(`
SELECT
COALESCE(SUM(COALESCE(ordervalue, 0) + COALESCE(orderamount, 0) + COALESCE(deliveryamt, 0)), 0) AS total_revenue,
COUNT(orderheaderid) AS total_orders
FROM orders
WHERE %s`, whereClause)
var result struct {
TotalRevenue float64
TotalOrders int
}
if err := r.db.Raw(totalsQuery, params...).Scan(&result).Error; err != nil {
return nil, err
}
summary.TotalRevenue = result.TotalRevenue
summary.TotalOrders = result.TotalOrders
// Counter sales, folded in before the average is taken — computing it from
// app orders alone and then adding POS revenue would report an average
// order value no order ever had.
posTotals, posDaily, err := r.posSalesTotals(tid, lid, fdate, tdate)
if err != nil {
return nil, err
}
summary.TotalRevenue = result.TotalRevenue + posTotals.Revenue
summary.TotalOrders = result.TotalOrders + posTotals.Orders
if summary.TotalOrders > 0 {
summary.AverageOrderValue = summary.TotalRevenue / float64(summary.TotalOrders)
}
chartQuery := fmt.Sprintf(`
SELECT
CAST(orderdate AS DATE) AS date,
@@ -856,17 +1022,21 @@ func (r *orderRepository) GetSalesSummary(tid, lid int, fdate, tdate string) (*m
WHERE %s
GROUP BY CAST(orderdate AS DATE)
ORDER BY date ASC`, whereClause)
var chartData []models.SalesSummaryChartData
if err := r.db.Raw(chartQuery, params...).Scan(&chartData).Error; err != nil {
return nil, err
}
if chartData == nil {
chartData = []models.SalesSummaryChartData{}
}
// Merged by day. A shop that only trades over the counter would otherwise
// show a flat line at zero on every chart in the product.
chartData = mergePosIntoChart(chartData, posDaily)
summary.ChartData = chartData
var topLocations []models.SalesSummaryTopLocation
if lid == 0 {
topWhere := "o.tenantid = ? AND o.orderstatus IN ('delivered', 'completed') AND o.configid = 1"
@@ -876,7 +1046,7 @@ func (r *orderRepository) GetSalesSummary(tid, lid int, fdate, tdate string) (*m
topWhere += " AND o.orderdate::date BETWEEN ? AND ?"
topParams = append(topParams, fdate, tdate)
}
cleanTopLocQuery := fmt.Sprintf(`
SELECT
COALESCE(l.locationname, 'Unknown') AS locationname,
@@ -887,17 +1057,17 @@ func (r *orderRepository) GetSalesSummary(tid, lid int, fdate, tdate string) (*m
GROUP BY l.locationid, l.locationname
ORDER BY revenue DESC
LIMIT 5`, topWhere)
if err := r.db.Raw(cleanTopLocQuery, topParams...).Scan(&topLocations).Error; err != nil {
return nil, err
}
}
if topLocations == nil {
topLocations = []models.SalesSummaryTopLocation{}
}
summary.TopLocations = topLocations
return &summary, nil
}
@@ -1220,84 +1390,38 @@ func (r *orderRepository) createOrderTx(tx *gorm.DB, data models.Orders) (models
}
// 🛠️ Step 0: Lock every (tenantid, locationid, productid) row this order
// touches before checking availability. Without this, two concurrent
// orders for the same product can both read "stock available" before
// either commits its deduction, oversell the item, and drive stock
// negative. Locking productlocations — the row the stock computation is
// already keyed against — serializes conflicting orders instead.
// touches, then check availability under those locks.
//
// Locks are acquired in a fixed (productid, locationid) order so that
// two orders sharing overlapping products always contend for them in
// the same sequence, avoiding a lock-ordering deadlock between the two
// transactions (as opposed to just making each individually block).
type lockTarget struct {
productid int
locationid int
}
seen := make(map[lockTarget]bool)
locks := make([]lockTarget, 0, len(data.Items))
// Both now live in stockLedger.go, shared with the POS ingest — one
// implementation of the rule that stops overselling, rather than one per
// caller waiting to drift out of step with the others. Behaviour here is
// unchanged, including legacyOrderQty's truncate-then-floor-at-1, which is
// what this path has always done.
lines := make([]stockLine, 0, len(data.Items))
for _, item := range data.Items {
itemLocID := item.Locationid
if itemLocID == 0 {
itemLocID = locID
}
lt := lockTarget{productid: item.Productid, locationid: itemLocID}
if !seen[lt] {
seen[lt] = true
locks = append(locks, lt)
}
lines = append(lines, stockLine{
Productid: item.Productid,
Locationid: itemLocID,
Productname: item.Productname,
Quantity: item.Orderqty,
})
}
sort.Slice(locks, func(a, b int) bool {
if locks[a].productid != locks[b].productid {
return locks[a].productid < locks[b].productid
}
return locks[a].locationid < locks[b].locationid
})
for _, lt := range locks {
var locked int
lockQuery := `SELECT productlocationid FROM productlocations WHERE tenantid = ? AND locationid = ? AND productid = ? FOR UPDATE`
if err := tx.Raw(lockQuery, data.Tenantid, lt.locationid, lt.productid).Scan(&locked).Error; err != nil {
tx.Rollback()
return models.Orders{}, fmt.Errorf("failed to lock stock for product %d: %w", lt.productid, err)
}
if err := lockStockRows(tx, data.Tenantid, lines); err != nil {
tx.Rollback()
return models.Orders{}, err
}
// 🛠️ Step 1: Pre-validate stock availability for all items before placing order
for _, item := range data.Items {
itemLocID := item.Locationid
if itemLocID == 0 {
itemLocID = locID
}
requestedQty := int(item.Orderqty)
if requestedQty <= 0 {
requestedQty = 1
}
var availableStock int
stockQuery := `
SELECT COALESCE(
SUM(CASE WHEN LOWER(stocktype) = 'in' THEN quantity ELSE 0 END) -
SUM(CASE WHEN LOWER(stocktype) = 'out' THEN quantity ELSE 0 END),
0
)
FROM productstocks
WHERE productid = ? AND tenantid = ? AND locationid = ?
`
if err := tx.Raw(stockQuery, item.Productid, data.Tenantid, itemLocID).Scan(&availableStock).Error; err != nil {
tx.Rollback()
return models.Orders{}, fmt.Errorf("failed to verify stock for product %d: %w", item.Productid, err)
}
// If stock tracking exists and available stock is less than requested quantity, block order placement
if availableStock < requestedQty {
tx.Rollback()
pName := item.Productname
if pName == "" {
pName = fmt.Sprintf("ID %d", item.Productid)
}
return models.Orders{}, fmt.Errorf("insufficient stock for product '%s': requested %d, available %d", pName, requestedQty, availableStock)
}
if err := assertStockAvailable(tx, data.Tenantid, lines, func(l stockLine) int {
return legacyOrderQty(l.Quantity)
}); err != nil {
tx.Rollback()
return models.Orders{}, err
}
// 🛠️ Step 2: Create Order Header
@@ -1331,28 +1455,17 @@ func (r *orderRepository) createOrderTx(tx *gorm.DB, data models.Orders) (models
return models.Orders{}, err
}
qty := int(item.Orderqty)
if qty <= 0 {
qty = 1
}
stock := models.Productstock{
Tenantid: data.Tenantid,
Stockdate: time.Now(),
Locationid: itemLocID,
Productid: item.Productid,
Quantity: qty,
Stocktype: "out",
Status: "Active",
}
if err := tx.Table("productstocks").Create(&stock).Error; err != nil {
// Writes the "out" entry and re-derives the location's availability
// flag from the balance it produced.
if err := recordStockOut(
tx,
data.Tenantid,
stockLine{Productid: item.Productid, Locationid: itemLocID},
legacyOrderQty(item.Orderqty),
); err != nil {
tx.Rollback()
return models.Orders{}, err
}
// Re-derive the location's availability flag from the ledger balance
// this "out" entry just produced.
syncProductLocationStatus(tx, data.Tenantid, itemLocID, item.Productid)
}
// Deliberately not committed: the caller owns the transaction boundary.
@@ -2138,7 +2251,7 @@ func (r *orderRepository) GetTimeSeries(tenantID, locationID int, granularity, f
var locFilter, dateFilter string
var params []interface{}
// subquery params
params = append(params, tenantID)
if locationID != 0 {
@@ -2149,7 +2262,7 @@ func (r *orderRepository) GetTimeSeries(tenantID, locationID int, granularity, f
dateFilter = " AND o2.orderdate::date BETWEEN ? AND ?"
params = append(params, fromDate, toDate)
}
// main query params
params = append(params, tenantID)
if locationID != 0 {
@@ -2194,7 +2307,7 @@ func (r *orderRepository) GetTimeSeries(tenantID, locationID int, granularity, f
if err := r.db.Raw(query, params...).Scan(&data).Error; err != nil {
return nil, err
}
if data == nil {
data = []models.TimeSeriesData{}
}