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{}
}

195
repositories/posPresence.go Normal file
View File

@@ -0,0 +1,195 @@
package repositories
import (
"context"
"fmt"
"strconv"
"time"
"nearle/db"
"nearle/models"
"github.com/redis/go-redis/v9"
)
// POS terminal presence, in Redis.
//
// ### Why Redis and not a table
//
// A heartbeat is a fact with an expiry date. Written to Postgres it needs a
// row per till updated twice a minute — around 288,000 writes a day across a
// hundred terminals — and a reaper job to mark a till offline once it stops,
// because a row that says "online" has no way of ageing out on its own.
//
// A Redis key with a TTL does the ageing for free. A till that loses power
// stops refreshing, the key expires, and it disappears from the board without
// anything having to notice. That is the whole design.
//
// ### Keys
//
// pos:terminal:{terminalcode} HASH, TTL 90s — one till's state
// pos:location:{locationid}:terminals SET, no TTL — which tills a shop has
//
// The set has no TTL on purpose, mirroring how `city:{tenantid}:active_deliveries`
// is treated in the express backend: it is an index of what exists, not a claim
// that any of it is alive right now. Membership means "this till has been seen
// here"; liveness is whether the hash still exists.
const (
// Three missed heartbeats. Two would make an ordinary GPRS hiccup look like
// a dead till; five would take two and a half minutes to notice a real one.
posPresenceTTL = 90 * time.Second
posTerminalKeyFmt = "pos:terminal:%s"
posLocationKeyFmt = "pos:location:%s:terminals"
)
type PosPresenceRepository interface {
Record(ctx context.Context, health models.PosHealth) error
Terminal(ctx context.Context, terminalID string) (map[string]string, error)
Location(ctx context.Context, locationID string) ([]map[string]string, error)
}
type posPresenceRepository struct{}
func NewPosPresenceRepository() PosPresenceRepository { return &posPresenceRepository{} }
// Record writes one heartbeat and refreshes its TTL.
func (r *posPresenceRepository) Record(ctx context.Context, health models.PosHealth) error {
if db.Rdb == nil {
return fmt.Errorf("redis is not configured")
}
if health.Terminalid == "" {
return fmt.Errorf("heartbeat has no terminal id")
}
terminalKey := fmt.Sprintf(posTerminalKeyFmt, health.Terminalid)
fields := map[string]any{
"terminal_id": health.Terminalid,
"location_id": health.Locationid,
"store_name": health.Storename,
"app_version": health.Appversion,
"status": health.Status,
"pending_bills": health.Pendingbills,
"pending_registrations": health.Pendingregistrations,
"oldest_pending_at": health.Oldestpendingat,
"today_bills": health.Todaybills,
"today_amount": health.Todayamount,
"last_bill_at": health.Lastbillat,
"reported_at": health.Reportedat,
// Stamped here as well as at the till. The two disagreeing by more than
// a few seconds means the terminal's clock is wrong — which matters,
// because bills are filed under the business date the till decided.
"received_at": time.Now().UTC().Format(time.RFC3339),
}
// Device readings only when the till actually reported them. A build that
// does not collect battery level must not leave one behind saying 0%.
if health.Batterylevel != nil {
fields["battery_level"] = *health.Batterylevel
}
if health.Batterycharging != nil {
fields["battery_charging"] = *health.Batterycharging
}
if health.Storagefreemb != nil {
fields["storage_free_mb"] = *health.Storagefreemb
}
if health.Printerreachable != nil {
fields["printer_reachable"] = *health.Printerreachable
}
if health.Drawerstatus != nil {
fields["drawer_status"] = *health.Drawerstatus
}
// HSet leaves untouched fields in place, so a reading that stops being
// reported would otherwise linger for ever at its last value. Clearing the
// absent ones keeps the hash honest about what this till currently knows.
stale := make([]string, 0, 5)
for field, reported := range map[string]bool{
"battery_level": health.Batterylevel != nil,
"battery_charging": health.Batterycharging != nil,
"storage_free_mb": health.Storagefreemb != nil,
"printer_reachable": health.Printerreachable != nil,
"drawer_status": health.Drawerstatus != nil,
} {
if !reported {
stale = append(stale, field)
}
}
pipe := db.Rdb.TxPipeline()
pipe.HSet(ctx, terminalKey, fields)
if len(stale) > 0 {
pipe.HDel(ctx, terminalKey, stale...)
}
pipe.Expire(ctx, terminalKey, posPresenceTTL)
if health.Locationid != "" {
// No TTL: this is the list of tills a shop has, not a claim that any of
// them is alive. Liveness is whether the hash above still exists.
pipe.SAdd(ctx, fmt.Sprintf(posLocationKeyFmt, health.Locationid), health.Terminalid)
}
_, err := pipe.Exec(ctx)
return err
}
// Terminal returns one till's last known state, or nil if it has gone quiet.
func (r *posPresenceRepository) Terminal(ctx context.Context, terminalID string) (map[string]string, error) {
if db.Rdb == nil {
return nil, fmt.Errorf("redis is not configured")
}
fields, err := db.Rdb.HGetAll(ctx, fmt.Sprintf(posTerminalKeyFmt, terminalID)).Result()
if err != nil && err != redis.Nil {
return nil, err
}
if len(fields) == 0 {
// Expired or never seen. Both mean "not reporting", which is what the
// caller needs to know; distinguishing them would need a durable record
// this deliberately does not keep.
return nil, nil
}
return fields, nil
}
// Location returns every till registered at a shop, live or dark.
//
// A till whose key has expired comes back as a stub with status "offline"
// rather than being omitted. Omitting it would make a dead terminal
// indistinguishable from one that was never installed — and the dead one is
// precisely what somebody is looking for.
func (r *posPresenceRepository) Location(ctx context.Context, locationID string) ([]map[string]string, error) {
if db.Rdb == nil {
return nil, fmt.Errorf("redis is not configured")
}
members, err := db.Rdb.SMembers(ctx, fmt.Sprintf(posLocationKeyFmt, locationID)).Result()
if err != nil && err != redis.Nil {
return nil, err
}
out := make([]map[string]string, 0, len(members))
for _, terminalID := range members {
fields, err := r.Terminal(ctx, terminalID)
if err != nil {
return nil, err
}
if fields == nil {
fields = map[string]string{
"terminal_id": terminalID,
"location_id": locationID,
"status": "offline",
// Says why it is being reported offline, rather than leaving a
// reader to guess whether the till said so or simply vanished.
"reason": "no heartbeat within " + strconv.Itoa(int(posPresenceTTL.Seconds())) + "s",
}
}
out = append(out, fields)
}
return out, nil
}

View File

@@ -0,0 +1,700 @@
package repositories
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"nearle/models"
"gorm.io/gorm"
)
// Ingestion for the Nearle POS terminal.
//
// Bills arrive here already rung up and paid for — the till is the system of
// record until we say otherwise, and it holds its own copy for a week on the
// strength of our acknowledgement. Two consequences shape everything below.
//
// **A duplicate is a success.** Delivery is at-least-once: a lost ack makes a
// terminal re-send bills that are already banked. Reporting those as failures
// would strand a day of takings on the till for ever. So a bill we already hold
// is accepted, silently, without touching stock again.
//
// **Acknowledge only after the commit.** A bill named in the ack is one the
// terminal is entitled to delete. Saying so before the transaction lands would
// trade a real sale for a queue position.
//
// The commit itself is deliberately not reimplemented here. Each bill runs
// through createOrderTx, the same path an app order and a spreadsheet import
// take, so stock deduction, the per-product row locks that prevent overselling,
// the ledger entries and sequence allocation stay shared rather than forked.
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)
}
type posRepository struct {
db *gorm.DB
// Held rather than embedded so the order machinery is reached explicitly.
orders *orderRepository
}
func NewPosRepository(db *gorm.DB) PosRepository {
return &posRepository{db: db, orders: &orderRepository{db: db}}
}
// resolvePosStore turns the terminal's store_id into an authorised outlet.
//
// The till sends a location and nothing else. The tenant is looked up from it
// here and never accepted from the wire: a terminal that could name its own
// tenant could post sales into somebody else's books.
func (r *posRepository) resolvePosStore(storeID string) (*offlineLocationContext, error) {
locationID, err := strconv.Atoi(strings.TrimSpace(storeID))
if err != nil || locationID <= 0 {
return nil, fmt.Errorf("store_id %q is not a location id; configure the terminal's Store ID with the numeric locationid", storeID)
}
var tenantID int
err = r.db.Raw(
`SELECT COALESCE(MIN(tenantid), 0) FROM tenantlocations WHERE locationid = ?`,
locationID,
).Scan(&tenantID).Error
if err != nil {
return nil, err
}
if tenantID <= 0 {
return nil, fmt.Errorf("no outlet is registered with locationid %d", locationID)
}
return r.orders.resolveOfflineLocationContext(tenantID, locationID)
}
// IngestOrders commits a batch of counter bills and reports what landed.
//
// A failure to resolve the outlet at all returns an error rather than an ack,
// so the terminal treats the outcome as unknown and retries. A failure on one
// bill is reported against that bill alone and the rest still commit.
func (r *posRepository) IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error) {
ack := models.NewPosAck(batch.Batchid)
if len(batch.Orders) == 0 {
return ack, nil
}
ctx, err := r.resolvePosStore(batch.Storeid)
if err != nil {
return nil, err
}
products, err := r.orders.loadOfflineProducts(ctx.Tenantid, ctx.Locationid)
if err != nil {
return nil, err
}
if len(products) == 0 {
return nil, fmt.Errorf("outlet '%s' has no products stocked against it", ctx.Locationname)
}
for _, order := range batch.Orders {
if strings.TrimSpace(order.Id) == "" {
// Nothing to key on, so it can never be deduplicated. Refusing it
// is safer than admitting a bill that would double on every retry.
ack.Reject("", "order is missing its id")
continue
}
if reason := r.importPosOrder(ctx, products, batch.Batchid, order); reason != "" {
ack.Reject(order.Id, reason)
continue
}
ack.Accept(order.Id)
}
return ack, nil
}
// importPosOrder commits one bill, or leaves nothing behind.
//
// Returns an empty string on success — including the case where the bill was
// already held, which is a success from the terminal's point of view.
//
// The bill lands in pos_orders at full fidelity, and the stock it consumed goes
// through the same productstocks ledger an app order uses. Those two facts pull
// in opposite directions and both matter: the bill is its own kind of document
// and deserves its own table, but stock is one number per shelf and must not be
// tracked twice.
func (r *posRepository) importPosOrder(
ctx *offlineLocationContext,
products map[int]offlineProduct,
batchID string,
order models.PosOrder,
) string {
if len(order.Items) == 0 {
return "bill has no items"
}
saleDate, err := parsePosSaleDate(order.Createdat)
if err != nil {
return err.Error()
}
// The till has already apportioned bill-level discounts across its lines to
// get the tax right, but it sends each line at its own pre-apportionment
// value. Left alone, the item rows would sum to the subtotal while the
// header carried the total, and every report that adds up lines would
// disagree with the one that reads the header.
//
// So the lines are scaled onto what was actually collected. The till's own
// figures stay authoritative for the bill as a whole; this only decides how
// that whole is attributed across the lines inside it.
netAmount := order.Total - order.Roundoff
lineSum := 0.0
taxSum := 0.0
for _, item := range order.Items {
lineSum += item.Linetotal
taxSum += item.Tax
}
amountFactor := 1.0
if lineSum > 0 && netAmount > 0 {
amountFactor = netAmount / lineSum
}
taxFactor := 1.0
if taxSum > 0 && order.Tax > 0 {
taxFactor = order.Tax / taxSum
}
items := make([]models.PosOrderItems, 0, len(order.Items))
lines := make([]stockLine, 0, len(order.Items))
var taxTotal float64
for _, raw := range order.Items {
productID, err := strconv.Atoi(strings.TrimSpace(raw.Productid))
if err != nil || productID <= 0 {
return fmt.Sprintf("line '%s' has product_id %q, which is not a catalogue id", raw.Name, raw.Productid)
}
// Membership of this map is the ownership check. A product absent from
// it is either another tenant's or not stocked here, and either way the
// bill is refused rather than posted against a catalogue it has no
// claim on.
product, ok := products[productID]
if !ok {
return fmt.Sprintf("product %d is not stocked at %s", productID, ctx.Locationname)
}
if raw.Quantity <= 0 {
return fmt.Sprintf("product '%s' has a quantity of %g; it must be greater than zero", product.Productname, raw.Quantity)
}
landing := raw.Linetotal * amountFactor
taxAmount := raw.Tax * taxFactor
gross := raw.Unitprice * raw.Quantity
discount := gross - landing
if discount < 0 {
discount = 0
}
taxTotal += taxAmount
items = append(items, models.PosOrderItems{
Tenantid: ctx.Tenantid,
Locationid: ctx.Locationid,
Productid: productID,
Productname: product.Productname,
Barcode: raw.Barcode,
Unitname: product.Productunit,
Quantity: raw.Quantity,
Unitprice: raw.Unitprice,
Discountamount: discount,
Gstrate: raw.Gstrate,
Taxamount: taxAmount,
Linetotal: landing,
})
lines = append(lines, stockLine{
Productid: productID,
Locationid: ctx.Locationid,
Productname: product.Productname,
Quantity: raw.Quantity,
})
}
paymentMode := "cash"
if len(order.Payments) > 0 {
// The largest tender names the bill. A split paid mostly by card with
// ten rupees of change in cash is a card sale in every report anyone
// actually reads — the full split is kept in Paymentsjson regardless.
largest := order.Payments[0]
for _, p := range order.Payments[1:] {
if p.Amount > largest.Amount {
largest = p
}
}
if m := strings.ToLower(strings.TrimSpace(largest.Method)); m != "" {
paymentMode = m
}
}
tx := r.db.Begin()
if tx.Error != nil {
return fmt.Sprintf("could not start a transaction: %v", tx.Error)
}
// Held for the life of the transaction, so a redelivery arriving at the
// same moment waits here and then sees the committed row rather than racing
// past the check below and banking the sale twice. The unique index on
// terminalorderid would catch it either way; this turns a constraint
// violation into an orderly "already held".
lockKey := "possale:" + strings.ToUpper(strings.TrimSpace(order.Id))
if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext(?))`, lockKey).Error; err != nil {
tx.Rollback()
return fmt.Sprintf("could not lock bill %s: %v", order.Invoicenumber, err)
}
var already int
err = tx.Raw(
`SELECT COALESCE(COUNT(*), 0) FROM pos_orders WHERE terminalorderid = ?`,
strings.TrimSpace(order.Id),
).Scan(&already).Error
if err != nil {
tx.Rollback()
return fmt.Sprintf("could not check whether bill %s was already held: %v", order.Invoicenumber, err)
}
if already > 0 {
// Already banked. Accepted, not rejected — this is the ordinary result
// of a lost ack, and calling it a failure would leave the till holding
// a bill we have had all along. Stock is deliberately untouched.
tx.Rollback()
return ""
}
// Locks first, then availability, then the writes — the same order an app
// order takes, and the reason two tills selling the last unit cannot both
// succeed.
if err := lockStockRows(tx, ctx.Tenantid, lines); err != nil {
tx.Rollback()
return err.Error()
}
if err := assertStockAvailable(tx, ctx.Tenantid, lines, func(l stockLine) int {
return roundStockQty(l.Quantity)
}); err != nil {
tx.Rollback()
return err.Error()
}
customerID, err := r.orders.resolveOfflineCustomer(tx, ctx, posCustomerName(order), posCustomerMobile(order))
if err != nil {
tx.Rollback()
return fmt.Sprintf("could not resolve the customer: %v", err)
}
bill := models.PosOrders{
Terminalorderid: strings.TrimSpace(order.Id),
Invoicenumber: order.Invoicenumber,
Tenantid: ctx.Tenantid,
Locationid: ctx.Locationid,
Terminalid: order.Terminalid,
Cashiername: order.Cashier,
Customerid: customerID,
Customermobile: posCustomerMobile(order),
Customername: posCustomerName(order),
Billedat: saleDate,
// The day the sale was rung, not the day it arrived. A till that was
// offline overnight uploads yesterday's bills this morning, and every
// daily figure has to follow the sale rather than the upload.
Businessdate: saleDate.Format("2006-01-02"),
Subtotal: order.Subtotal,
Discount: order.Discount,
Taxamount: taxTotal,
Roundoff: order.Roundoff,
Total: order.Total,
Pointsearned: order.Pointsearned,
Pointsredeemed: order.Pointsredeemed,
Itemcount: len(items),
Paymentmode: paymentMode,
Paymentsjson: posJSON(order.Payments),
Promosjson: posJSON(order.Promos),
// Every jsonb column must carry valid JSON. Left at Go's zero value an
// empty string reaches Postgres and the whole insert fails with
// "invalid input syntax for type json" — taking the bill down with it.
Taxbreakdownjson: posJSON(order.Taxbreakdown),
Batchid: batchID,
Receivedat: time.Now(),
}
if err := tx.Create(&bill).Error; err != nil {
tx.Rollback()
return fmt.Sprintf("could not write bill %s: %v", order.Invoicenumber, err)
}
for i := range items {
items[i].Posorderid = bill.Posorderid
if err := tx.Create(&items[i]).Error; err != nil {
tx.Rollback()
return fmt.Sprintf("could not write a line of bill %s: %v", order.Invoicenumber, err)
}
// The same ledger an app order writes to. A second stock ledger for
// counter sales would mean the catalogue pull sends a till figures that
// ignore the till's own trading.
if err := recordStockOut(tx, ctx.Tenantid, lines[i], roundStockQty(lines[i].Quantity)); err != nil {
tx.Rollback()
return fmt.Sprintf("could not deduct stock for bill %s: %v", order.Invoicenumber, err)
}
}
if err := tx.Commit().Error; err != nil {
return fmt.Sprintf("could not commit bill %s: %v", order.Invoicenumber, err)
}
return ""
}
// posJSON encodes a payload column.
//
// Falls back to a JSON null rather than failing the bill: these columns exist
// to be read back later, and losing one is not a reason to refuse a sale the
// shopper has already paid for.
func posJSON(v any) string {
body, err := json.Marshal(v)
if err != nil || len(body) == 0 {
return "null"
}
return string(body)
}
func posCustomerName(order models.PosOrder) string {
if order.Customer == nil {
return ""
}
return order.Customer.Name
}
func posCustomerMobile(order models.PosOrder) string {
if order.Customer == nil {
return ""
}
return order.Customer.Mobile
}
// parsePosSaleDate reads the till's timestamp.
//
// The terminal sends ISO-8601. A blank one falls back to now; an unparseable
// one is refused, because importing a sale under the wrong date corrupts every
// daily revenue figure that reads it.
func parsePosSaleDate(raw string) (time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Now(), nil
}
for _, layout := range []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05.999999",
"2006-01-02 15:04:05",
} {
if t, err := time.Parse(layout, raw); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("unrecognised created_at %q", raw)
}
// IngestCustomers records shoppers registered at a till.
//
// Insert-if-absent, never an update. A registration is replayed freely, and a
// profile corrected at head office must not be reverted by a terminal replaying
// what it captured months ago.
func (r *posRepository) IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error) {
ack := models.NewPosAck(batch.Batchid)
if len(batch.Customers) == 0 {
return ack, nil
}
ctx, err := r.resolvePosStore(batch.Storeid)
if err != nil {
return nil, err
}
for _, customer := range batch.Customers {
mobile := strings.TrimSpace(customer.Mobile)
if strings.TrimSpace(customer.Id) == "" || mobile == "" {
ack.Reject(customer.Id, "registration is missing its id or mobile number")
continue
}
if err := r.upsertPosCustomer(ctx, customer, mobile); err != nil {
ack.Reject(customer.Id, err.Error())
continue
}
ack.Accept(customer.Id)
}
return ack, nil
}
// upsertPosCustomer attaches the shopper to this outlet's app location.
//
// Matched on contactno, which is what the rest of the system already keys a
// shopper on, so a shopper registered at a till and one who installed the app
// end up as one row rather than two.
func (r *posRepository) upsertPosCustomer(
ctx *offlineLocationContext,
customer models.PosCustomer,
mobile string,
) error {
name := strings.TrimSpace(customer.Name)
if name == "" {
name = "Counter Customer"
}
var existing int
err := r.db.Raw(
`SELECT COALESCE(MIN(customerid), 0) FROM customers WHERE contactno = ? AND applocationid = ?`,
mobile, ctx.Applocationid,
).Scan(&existing).Error
if err != nil {
return err
}
if existing > 0 {
// Already known. Accepted without a write — the terminal's copy is not
// newer than ours in any way we can establish.
return nil
}
// status 0 mirrors every other customer row in production, including ones
// actively placing orders. A different value here would make a shopper
// registered at the counter behave unlike all the others.
var created int
err = r.db.Raw(`
INSERT INTO customers (configid, firstname, lastname, contactno, email, gender, dob, applocationid, locationid, status, created, updated)
VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, 0, NOW(), NOW())
RETURNING customerid`,
ctx.Configid, name, mobile,
strings.TrimSpace(customer.Email),
strings.TrimSpace(customer.Gender),
strings.TrimSpace(customer.Dateofbirth),
ctx.Applocationid, ctx.Locationid,
).Scan(&created).Error
if err != nil {
return err
}
if created <= 0 {
return errors.New("failed to create the customer row")
}
return nil
}
// Catalogue answers a terminal's morning pull.
//
// Always a full snapshot today, and it says so. The terminal withdraws every
// product a snapshot omits, so answering a change set with is_delta false would
// empty the shelf — declaring false here is only safe because this really does
// return everything stocked at the outlet.
func (r *posRepository) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) {
ctx, err := r.resolvePosStore(storeID)
if err != nil {
return nil, err
}
if pageSize <= 0 || pageSize > 1000 {
pageSize = 500
}
if page < 0 {
page = 0
}
type row struct {
Productid int
Productname string
Productsku string
Categoryname string
Productunit string
Productbrand string
Price float64
Retailprice float64
Taxpercent float64
Stock float64
Status string
}
rows := make([]row, 0)
err = r.db.Raw(`
SELECT a.productid,
COALESCE(a.productname, '') AS productname,
COALESCE(a.productsku, '') AS productsku,
COALESCE(c.categoryname, '') AS categoryname,
COALESCE(a.productunit, '') AS productunit,
COALESCE(a.productbrand, '') AS productbrand,
CASE WHEN COALESCE(b.price, 0) > 0 THEN b.price ELSE COALESCE(a.retailprice, 0) END AS price,
COALESCE(a.retailprice, 0) AS retailprice,
COALESCE(a.taxpercent, 0) AS taxpercent,
COALESCE((
SELECT SUM(CASE WHEN LOWER(s.stocktype) = 'in' THEN s.quantity ELSE 0 END) -
SUM(CASE WHEN LOWER(s.stocktype) = 'out' THEN s.quantity ELSE 0 END)
FROM productstocks s
WHERE s.productid = a.productid AND s.tenantid = a.tenantid AND s.locationid = b.locationid
), 0) AS stock,
COALESCE(b.status, '') AS status
FROM products a
INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid
LEFT JOIN productcategories c ON a.categoryid = c.categoryid
WHERE a.tenantid = ? AND b.locationid = ?
ORDER BY a.productid
LIMIT ? OFFSET ?`,
ctx.Tenantid, ctx.Locationid, pageSize+1, page*pageSize,
).Scan(&rows).Error
if err != nil {
return nil, err
}
// One row beyond the page was requested purely to answer has_more without a
// second count query.
hasMore := len(rows) > pageSize
if hasMore {
rows = rows[:pageSize]
}
products := make([]models.PosCatalogueProduct, 0, len(rows))
for _, p := range rows {
// A productid of zero is bad data, not a product — live data has at
// least one, almost certainly an insert that never got a sequence
// value. Sending it would put a row on the till that can never be
// billed, because the ingest refuses any line whose id is not positive.
if p.Productid <= 0 {
continue
}
mrp := p.Retailprice
if mrp <= p.Price {
mrp = 0
}
// Indian GST is 0/5/12/18/28, but the column holds 3, 4, 6, 7, 9, 10,
// 15 and even -1 across live data. A negative rate would put negative
// tax on a bill and a negative figure in a slab on a filed return, so
// it is floored here rather than trusted.
gstRate := p.Taxpercent / 100
if gstRate < 0 {
gstRate = 0
}
// Withdrawn from sale when there is no selling price. Neither
// productlocations.price nor retailprice is set on much of the estate —
// only productcost is — and a till that can ring an item up at ₹0 is
// worse than one that cannot ring it up at all. Pricing the product
// makes it sellable; nothing here needs changing.
sellable := p.Price > 0 &&
!strings.EqualFold(strings.TrimSpace(p.Status), "outofstock")
products = append(products, models.PosCatalogueProduct{
Id: strconv.Itoa(p.Productid),
Name: p.Productname,
Barcode: posBarcode(p.Productid, p.Productsku),
Sku: p.Productsku,
Category: posCategory(p.Categoryname),
Price: p.Price,
Mrp: mrp,
Stock: p.Stock,
Unit: posUnit(p.Productunit),
Gstrate: gstRate,
Brand: p.Productbrand,
Isactive: sellable,
})
}
return &models.PosCatalogueResponse{
// A revision the terminal stores and sends back on its next pull. Tied
// to the outlet and the moment, so a shop that has pulled today can be
// told it is already current.
Revision: fmt.Sprintf("loc%d-%s", ctx.Locationid, time.Now().UTC().Format("20060102T150405")),
Isdelta: false,
Hasmore: hasMore,
Products: products,
Customers: make([]models.PosCatalogueCustomer, 0),
Retiredids: make([]string, 0),
}, nil
}
// posBarcode decides what the till scans this product by.
//
// The terminal holds a **unique** index on barcode, so whatever this returns has
// to be distinct across the whole catalogue or the import fails outright.
//
// `products.productsku` cannot be trusted for that. Measured against live data:
// 6,245 products carry only 93 distinct SKUs, and the single value "1" is used
// by 5,794 of them. Mapping SKU straight to barcode would collapse most of the
// catalogue onto one row.
//
// So a SKU is used only when it looks like a real scannable code — 8 to 14
// digits, the shape of an EAN-8, UPC-A or EAN-13 — and otherwise the product id
// stands in. The id is unique by construction, which keeps the import working
// today; the day real barcodes are populated, scanning starts working on its own
// with no change here.
//
// Until then, scanning a physical barcode at the till will not find anything.
// That is a data problem, not a code one.
func posBarcode(productID int, sku string) string {
sku = strings.TrimSpace(sku)
if len(sku) >= 8 && len(sku) <= 14 {
digitsOnly := true
for _, r := range sku {
if r < '0' || r > '9' {
digitsOnly = false
break
}
}
if digitsOnly {
return sku
}
}
return strconv.Itoa(productID)
}
// posCategory maps a category name onto one of the terminal's fixed buckets.
//
// The till ships a closed enum, so anything unrecognised has to land somewhere;
// grocery is the catch-all it already uses for uncategorised stock.
func posCategory(name string) string {
switch strings.ToLower(strings.TrimSpace(name)) {
case "dairy":
return "dairy"
case "fruits", "fruit":
return "fruits"
case "vegetables", "vegetable":
return "vegetables"
case "beverages", "beverage", "drinks":
return "beverages"
case "snacks", "snack":
return "snacks"
case "personal care", "personalcare":
return "personalCare"
case "household", "home care", "homecare":
return "household"
default:
return "grocery"
}
}
// posUnit maps a unit of measure onto the terminal's enum, defaulting to pieces.
func posUnit(unit string) string {
switch strings.ToLower(strings.TrimSpace(unit)) {
case "kg", "kilogram", "kilo":
return "kilogram"
case "g", "gram", "grams":
return "gram"
case "l", "litre", "liter":
return "litre"
case "ml", "millilitre", "milliliter":
return "millilitre"
case "pack", "packet":
return "pack"
default:
return "piece"
}
}

View File

@@ -0,0 +1,97 @@
package repositories
import "testing"
// The terminal holds a unique index on barcode, so this rule decides whether a
// catalogue import succeeds at all. Measured against live data when it was
// written: 6,245 products, 93 distinct SKUs, and "1" used by 5,794 of them.
func TestPosBarcodeFallsBackToProductIdWhenTheSkuIsNotScannable(t *testing.T) {
cases := []struct {
name string
productID int
sku string
want string
}{
{"the SKU almost every product shares", 844, "1", "844"},
{"blank SKU", 845, "", "845"},
{"whitespace only", 846, " ", "846"},
{"too short to be a barcode", 847, "1234567", "847"},
{"too long to be a barcode", 848, "123456789012345", "848"},
{"not digits", 849, "SKU-ABC-123", "849"},
{"digits with a space", 850, "1234 5678", "850"},
// Real scannable codes are used as-is, so the day the catalogue carries
// them scanning starts working with no code change.
{"EAN-8", 851, "12345678", "12345678"},
{"UPC-A", 852, "012345678905", "012345678905"},
{"EAN-13", 853, "8901030865278", "8901030865278"},
{"padded EAN-13", 854, " 8901030865278 ", "8901030865278"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := posBarcode(c.productID, c.sku); got != c.want {
t.Errorf("posBarcode(%d, %q) = %q, want %q", c.productID, c.sku, got, c.want)
}
})
}
}
func TestPosBarcodesAreUniqueAcrossACatalogueOfSharedSkus(t *testing.T) {
// The failure this exists to prevent: a whole catalogue collapsing onto one
// barcode and the import being rejected by the terminal's unique index.
seen := make(map[string]int)
for id := 844; id < 844+500; id++ {
barcode := posBarcode(id, "1")
if first, clash := seen[barcode]; clash {
t.Fatalf("products %d and %d both produced barcode %q", first, id, barcode)
}
seen[barcode] = id
}
}
func TestRoundStockQtyNeverUnderDeducts(t *testing.T) {
// productstocks.quantity is an integer column and a counter sells 1.5 kg of
// onions. Rounding up keeps recorded stock at or below what is on the shelf;
// truncating would let the shop oversell a little more with every sale.
cases := []struct {
quantity float64
want int
}{
{1, 1},
{1.5, 2},
{0.25, 1},
{2.0, 2},
{2.01, 3},
{0, 1},
{-1, 1},
}
for _, c := range cases {
if got := roundStockQty(c.quantity); got != c.want {
t.Errorf("roundStockQty(%g) = %d, want %d", c.quantity, got, c.want)
}
}
}
func TestLegacyOrderQtyIsUnchanged(t *testing.T) {
// App orders have always truncated, and that behaviour is deliberately
// preserved rather than corrected — changing it would silently alter stock
// deduction for every order already flowing through createOrderTx.
cases := []struct {
quantity float64
want int
}{
{1, 1},
{1.5, 1},
{0.5, 1},
{3.9, 3},
{0, 1},
}
for _, c := range cases {
if got := legacyOrderQty(c.quantity); got != c.want {
t.Errorf("legacyOrderQty(%g) = %d, want %d", c.quantity, got, c.want)
}
}
}

182
repositories/stockLedger.go Normal file
View File

@@ -0,0 +1,182 @@
package repositories
import (
"fmt"
"math"
"sort"
"time"
"nearle/models"
"gorm.io/gorm"
)
// Shared stock machinery.
//
// Extracted from createOrderTx so that an order placed in the app and a bill
// rung up at a counter deduct stock through exactly the same code. Two
// implementations of the rule that stops overselling would drift, and the first
// anyone would know about it is a shelf that is empty in the database and full
// in the shop, or the reverse.
//
// None of these commit or roll back — the caller owns the transaction boundary,
// because what should happen to the rest of the work on failure is the caller's
// business, not the ledger's.
// stockLine is the minimum the ledger needs to know about one sold line.
//
// Deliberately not models.OrderDetail: the POS ingest writes its own tables and
// has no OrderDetail to hand, and coupling the ledger to one caller's row type
// is what forced the duplication this file removes.
type stockLine struct {
Productid int
Locationid int
Productname string
// Units sold. Fractional because a counter sells 1.5 kg of onions; the
// ledger itself is integer-only, and roundStockQty explains the gap.
Quantity float64
}
// roundStockQty turns a sold quantity into a ledger quantity.
//
// productstocks.quantity is an integer column, so fractional sales cannot be
// represented exactly. Rounding *up* is the conservative direction: 1.5 kg
// deducts 2, so the recorded stock is never higher than what is physically on
// the shelf. Truncating instead would under-deduct on every fractional sale and
// let the shop oversell a little more each time.
//
// This is a workaround, not a fix. A shop that sells much by weight needs the
// column to be numeric.
func roundStockQty(quantity float64) int {
if quantity <= 0 {
return 1
}
return int(math.Ceil(quantity - 1e-9))
}
// lockStockRows takes a row lock on every (tenant, location, product) the sale
// touches, before anything reads availability.
//
// Without it two concurrent sales of the same product can both read "in stock"
// before either commits its deduction, oversell the item and drive the balance
// negative. Locking productlocations — the row the stock computation is already
// keyed against — serialises conflicting sales instead.
//
// Locks are taken in a fixed (productid, locationid) order so two sales sharing
// products always contend in the same sequence. Without that ordering they
// deadlock against each other rather than merely blocking.
func lockStockRows(tx *gorm.DB, tenantID int, lines []stockLine) error {
type lockTarget struct {
productid int
locationid int
}
seen := make(map[lockTarget]bool, len(lines))
locks := make([]lockTarget, 0, len(lines))
for _, line := range lines {
lt := lockTarget{productid: line.Productid, locationid: line.Locationid}
if !seen[lt] {
seen[lt] = true
locks = append(locks, lt)
}
}
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
const q = `SELECT productlocationid FROM productlocations
WHERE tenantid = ? AND locationid = ? AND productid = ? FOR UPDATE`
if err := tx.Raw(q, tenantID, lt.locationid, lt.productid).Scan(&locked).Error; err != nil {
return fmt.Errorf("failed to lock stock for product %d: %w", lt.productid, err)
}
}
return nil
}
// availableStock is the ledger balance for one product at one location.
func availableStock(tx *gorm.DB, tenantID, locationID, productID int) (int, error) {
var available int
const q = `
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(q, productID, tenantID, locationID).Scan(&available).Error; err != nil {
return 0, fmt.Errorf("failed to verify stock for product %d: %w", productID, err)
}
return available, nil
}
// assertStockAvailable refuses the whole sale if any line cannot be met.
//
// Checked for every line before any is written, so a sale never lands
// half-deducted. Call it only with the locks from lockStockRows already held —
// otherwise the balance it reads can change before the deduction is written.
func assertStockAvailable(tx *gorm.DB, tenantID int, lines []stockLine, qtyOf func(stockLine) int) error {
for _, line := range lines {
available, err := availableStock(tx, tenantID, line.Locationid, line.Productid)
if err != nil {
return err
}
requested := qtyOf(line)
if available < requested {
name := line.Productname
if name == "" {
name = fmt.Sprintf("ID %d", line.Productid)
}
return fmt.Errorf(
"insufficient stock for product '%s': requested %d, available %d",
name, requested, available,
)
}
}
return nil
}
// recordStockOut writes the ledger entry for one sold line and re-derives the
// location's availability flag from the balance it just produced.
func recordStockOut(tx *gorm.DB, tenantID int, line stockLine, quantity int) error {
stock := models.Productstock{
Tenantid: tenantID,
Stockdate: time.Now(),
Locationid: line.Locationid,
Productid: line.Productid,
Quantity: quantity,
Stocktype: "out",
Status: "Active",
}
if err := tx.Table("productstocks").Create(&stock).Error; err != nil {
return err
}
syncProductLocationStatus(tx, tenantID, line.Locationid, line.Productid)
return nil
}
// legacyOrderQty is how createOrderTx has always turned an order quantity into
// a ledger quantity: truncate, then floor at 1.
//
// Preserved exactly rather than corrected, because changing it would silently
// alter stock deduction for every app order in production. It under-deducts a
// fractional line — 1.5 becomes 1 — which is why the POS path uses
// roundStockQty instead. Worth reconciling once someone owns the decision.
func legacyOrderQty(quantity float64) int {
q := int(quantity)
if q <= 0 {
q = 1
}
return q
}