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>
183 lines
6.0 KiB
Go
183 lines
6.0 KiB
Go
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
|
|
}
|