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:
@@ -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{}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user