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>
117 lines
4.1 KiB
Go
117 lines
4.1 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// cleanup removes everything the end-to-end proof wrote to the live database.
|
|
//
|
|
// Three things, in an order that leaves nothing half-undone: the stock the test
|
|
// bills consumed is returned, the bills themselves are deleted, and the price
|
|
// that was set to make a product sellable goes back to what it was.
|
|
//
|
|
// The two test bills are named explicitly rather than deleted by date or by
|
|
// "everything in pos_orders" — a table that will hold real takings tomorrow is
|
|
// not one to run an unbounded DELETE against.
|
|
const probeMobile = "9840012345"
|
|
|
|
var testOrderIDs = []string{
|
|
"11111111-2222-4333-8444-555555555555", // the HTTP probe
|
|
"99999999-8888-4777-8666-555555555555", // the MQTT probe
|
|
}
|
|
|
|
func cleanup(db *gorm.DB) {
|
|
var billIDs []int
|
|
db.Raw(`SELECT posorderid FROM pos_orders WHERE terminalorderid IN ?`,
|
|
testOrderIDs).Scan(&billIDs)
|
|
|
|
if len(billIDs) == 0 {
|
|
fmt.Println("no test bills found — nothing to undo")
|
|
}
|
|
|
|
// Stock first. Deleting the bills before returning what they consumed would
|
|
// leave the ledger short with nothing left to explain why.
|
|
var consumed []struct {
|
|
Productid int
|
|
Quantity float64
|
|
}
|
|
db.Raw(`SELECT productid, SUM(quantity) AS quantity
|
|
FROM pos_order_items WHERE posorderid IN ?
|
|
GROUP BY productid`, billIDs).Scan(&consumed)
|
|
|
|
for _, c := range consumed {
|
|
qty := int(c.Quantity)
|
|
if float64(qty) < c.Quantity {
|
|
qty++ // the ingest rounded up, so the reversal must too
|
|
}
|
|
if err := db.Exec(`
|
|
INSERT INTO productstocks (tenantid, stockdate, locationid, productid,
|
|
quantity, stocktype, status)
|
|
VALUES (?, NOW(), ?, ?, ?, 'in', 'Active')`,
|
|
tenantID, locationID, c.Productid, qty).Error; err != nil {
|
|
fmt.Println(" return stock:", err)
|
|
continue
|
|
}
|
|
fmt.Printf(" returned %d units of product %d\n", qty, c.Productid)
|
|
}
|
|
|
|
if len(billIDs) > 0 {
|
|
db.Exec(`DELETE FROM pos_order_items WHERE posorderid IN ?`, billIDs)
|
|
db.Exec(`DELETE FROM pos_orders WHERE posorderid IN ?`, billIDs)
|
|
fmt.Printf(" deleted %d test bill(s)\n", len(billIDs))
|
|
}
|
|
|
|
db.Exec(`UPDATE productlocations SET price = 0
|
|
WHERE tenantid=? AND locationid=? AND productid=?`,
|
|
tenantID, locationID, productID)
|
|
fmt.Printf(" product %d price restored to 0\n", productID)
|
|
|
|
// The shopper the probes created. Removed only when nothing references it —
|
|
// a customer row attached to a real order is not test data any more.
|
|
var referenced int
|
|
db.Raw(`SELECT COUNT(*) FROM orders WHERE customerid =
|
|
(SELECT MIN(customerid) FROM customers WHERE contactno = ?)`,
|
|
probeMobile).Scan(&referenced)
|
|
if referenced > 0 {
|
|
fmt.Printf(" customer %s left in place — %d order(s) reference it\n",
|
|
probeMobile, referenced)
|
|
} else {
|
|
res := db.Exec(`DELETE FROM customers WHERE contactno = ?`, probeMobile)
|
|
fmt.Printf(" removed %d probe customer row(s)\n", res.RowsAffected)
|
|
}
|
|
|
|
var balance float64
|
|
db.Raw(`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 tenantid=? AND locationid=? AND productid=?`,
|
|
tenantID, locationID, productID).Scan(&balance)
|
|
fmt.Printf(" product %d balance back to %.0f\n", productID, balance)
|
|
}
|
|
|
|
// showCustomer reports whether the registration probe reached the customers
|
|
// table, and under which id — the question an ack alone cannot answer.
|
|
func showCustomer(db *gorm.DB, mobile string) {
|
|
var rows []struct {
|
|
Customerid int
|
|
Firstname string
|
|
Contactno string
|
|
Applocationid int
|
|
}
|
|
db.Raw(`SELECT customerid, COALESCE(firstname,'') AS firstname,
|
|
COALESCE(contactno,'') AS contactno,
|
|
COALESCE(applocationid,0) AS applocationid
|
|
FROM customers WHERE contactno = ?`, mobile).Scan(&rows)
|
|
|
|
if len(rows) == 0 {
|
|
fmt.Printf(" no customer with contactno %s\n", mobile)
|
|
return
|
|
}
|
|
for _, r := range rows {
|
|
fmt.Printf(" customerid=%d %q contactno=%s applocid=%d\n",
|
|
r.Customerid, r.Firstname, r.Contactno, r.Applocationid)
|
|
}
|
|
}
|