Files
backend_fiesta/models/posorder.go
Suriya e3459a0f1c 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>
2026-08-03 17:48:00 +05:30

139 lines
6.3 KiB
Go

package models
import "time"
// Counter sales, stored at the fidelity the till actually rang them.
//
// Separate from `orders` on purpose. An app order and a counter bill are
// different documents: a bill carries a cashier, a terminal, a rounding
// adjustment, promo campaigns, loyalty movement and a payment split across
// several tenders, none of which `orders` has anywhere to put. Forcing one into
// the other's shape loses whichever fields do not fit, and the loss is silent.
//
// The cost of the split is that existing revenue queries do not see these rows
// until they are extended to union them in — done in orderRepository's summary
// queries, and the thing to remember when adding a new report.
//
// Stock is *not* separate: a counter sale writes the same productstocks "out"
// rows an app order does, through the same helper. Two stock ledgers would mean
// the catalogue pull sends a till figures that ignore its own sales.
// PosOrders is one counter bill.
type PosOrders struct {
Posorderid int `json:"posorderid" gorm:"primaryKey;autoIncrement;column:posorderid"`
// The UUID minted at the till. Globally unique by construction and the only
// thing that identifies this bill, so it carries a unique index: delivery is
// at-least-once and the same bill legitimately arrives more than once.
Terminalorderid string `json:"terminalorderid" gorm:"column:terminalorderid;uniqueIndex;not null"`
// Human-facing, and unique only per terminal — a till that was replaced
// restarts its own series, so gaps are normal and duplicates across
// terminals are expected.
Invoicenumber string `json:"invoicenumber" gorm:"column:invoicenumber;index"`
Tenantid int `json:"tenantid" gorm:"column:tenantid;index"`
Locationid int `json:"locationid" gorm:"column:locationid;index"`
// Which physical till, e.g. "T4A9". Free text: nothing keys on it, but a
// support call starts with it.
Terminalid string `json:"terminalid" gorm:"column:terminalid;index"`
Cashiername string `json:"cashiername" gorm:"column:cashiername"`
// Resolved against the customers table. Zero for a walk-in.
Customerid int `json:"customerid" gorm:"column:customerid;index"`
Customermobile string `json:"customermobile" gorm:"column:customermobile"`
Customername string `json:"customername" gorm:"column:customername"`
// When the sale was rung, not when it reached us — a till that was offline
// for a day uploads bills whose Billedat is yesterday, and every daily
// figure must use this rather than Receivedat.
Billedat time.Time `json:"billedat" gorm:"column:billedat;index"`
// YYYY-MM-DD of Billedat, denormalised so a day's takings are one indexed
// equality match rather than a range scan with timezone arithmetic.
Businessdate string `json:"businessdate" gorm:"column:businessdate;index"`
Subtotal float64 `json:"subtotal" gorm:"column:subtotal"`
Discount float64 `json:"discount" gorm:"column:discount"`
Taxamount float64 `json:"taxamount" gorm:"column:taxamount"`
// The paise adjustment printed on the bill. Kept because total is not
// derivable from the other columns without it.
Roundoff float64 `json:"roundoff" gorm:"column:roundoff"`
// What the shopper actually paid. The figure every revenue report sums.
Total float64 `json:"total" gorm:"column:total"`
Pointsearned int `json:"pointsearned" gorm:"column:pointsearned"`
Pointsredeemed int `json:"pointsredeemed" gorm:"column:pointsredeemed"`
Itemcount int `json:"itemcount" gorm:"column:itemcount"`
// The largest tender, for the common "how did they pay" grouping.
Paymentmode string `json:"paymentmode" gorm:"column:paymentmode;index"`
// The full split, verbatim. A bill can be part cash, part card, part
// loyalty, and collapsing that to one mode would lose the reconciliation a
// cashier settles their drawer against.
Paymentsjson string `json:"paymentsjson" gorm:"column:paymentsjson;type:jsonb"`
// Campaigns that fired, stored as amounts rather than rules — a bill read
// back years later must show what was given, not what today's rules give.
Promosjson string `json:"promosjson" gorm:"column:promosjson;type:jsonb"`
// GST per slab, as printed on the tax invoice.
Taxbreakdownjson string `json:"taxbreakdownjson" gorm:"column:taxbreakdownjson;type:jsonb"`
// Which upload carried this bill, and when it landed. Kept for tracing a
// terminal's complaint back to a specific batch.
Batchid string `json:"batchid" gorm:"column:batchid;index"`
Receivedat time.Time `json:"receivedat" gorm:"column:receivedat"`
Created time.Time `json:"created" gorm:"column:created;autoCreateTime"`
Updated time.Time `json:"updated" gorm:"column:updated;autoUpdateTime"`
Items []PosOrderItems `json:"items" gorm:"-"`
}
func (PosOrders) TableName() string {
return "pos_orders"
}
// PosOrderItems is one line of a counter bill.
type PosOrderItems struct {
Posorderitemid int `json:"posorderitemid" gorm:"primaryKey;autoIncrement;column:posorderitemid"`
Posorderid int `json:"posorderid" gorm:"column:posorderid;index"`
Tenantid int `json:"tenantid" gorm:"column:tenantid;index"`
Locationid int `json:"locationid" gorm:"column:locationid;index"`
Productid int `json:"productid" gorm:"column:productid;index"`
// Snapshotted rather than joined. A product renamed or withdrawn next month
// must not change what a bill from today says it sold.
Productname string `json:"productname" gorm:"column:productname"`
Barcode string `json:"barcode" gorm:"column:barcode"`
Unitname string `json:"unitname" gorm:"column:unitname"`
// Fractional: a counter sells 1.5 kg of onions. Note that productstocks
// cannot represent that — see roundStockQty.
Quantity float64 `json:"quantity" gorm:"column:quantity"`
Unitprice float64 `json:"unitprice" gorm:"column:unitprice"`
Discountamount float64 `json:"discountamount" gorm:"column:discountamount"`
// Stored as a fraction (0.18), matching how the till holds it.
Gstrate float64 `json:"gstrate" gorm:"column:gstrate"`
Taxamount float64 `json:"taxamount" gorm:"column:taxamount"`
// What this line contributed to the bill total, after its share of every
// discount. The lines sum to the bill's Total less Roundoff.
Linetotal float64 `json:"linetotal" gorm:"column:linetotal"`
Created time.Time `json:"created" gorm:"column:created;autoCreateTime"`
}
func (PosOrderItems) TableName() string {
return "pos_order_items"
}