Record the probes that touched live data
seedprices is the only account of what the 15 seeded retail prices were and how to put the zeros back — products at 1135 and 1185 were all at 0, so nothing was sellable and the terminal could not be exercised at all. It refuses to overwrite a price a human already set, so re-running it is safe. termfixcleanup removes the one bill posted to prove the terminalid fix against production. Named by its own terminalorderid rather than by date or by "the newest row": pos_orders holds real takings and is not a table to run an unbounded DELETE against. healthproof carries a warning it did not have when it was written. MQTT_USER in .env is pos_ingest, which the ACL denies publish on the health topic, and Mosquitto answers an ACL-denied QoS 1 publish with a PUBACK before discarding it. So the tool connects, reports success, and nothing arrives — indistinguishable from a dead consumer, which is how it read for an hour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
141
scratch/healthproof/main.go
Normal file
141
scratch/healthproof/main.go
Normal file
@@ -0,0 +1,141 @@
|
||||
// Proof that the 30-second health heartbeat works end to end, over MQTT.
|
||||
//
|
||||
// Publishes a heartbeat to the live broker as a throwaway terminal, then polls
|
||||
// the public API until it shows online — and keeps polling past the TTL so the
|
||||
// automatic expiry is visible too. Nothing is written to Postgres; presence
|
||||
// lives in Redis under a TTL and cleans itself up.
|
||||
//
|
||||
// REQUIRES pos_terminal CREDENTIALS. The MQTT_USER in .env is pos_ingest, which
|
||||
// the broker ACL deliberately denies publish on the health topic — it may only
|
||||
// write acks and the catalogue. Running this with the ingest account connects
|
||||
// fine and then silently drops every publish, because Mosquitto answers an
|
||||
// ACL-denied QoS 1 publish with a PUBACK and discards it. That looks exactly
|
||||
// like a broken consumer and cost an hour of misdiagnosis; set MQTT_USER and
|
||||
// MQTT_PASSWORD to the terminal account before believing a negative result.
|
||||
//
|
||||
// The HTTP path needs none of this — see POST /pos/health, which is what the
|
||||
// fix on v1.3.96 added and how the endpoint was actually verified.
|
||||
//
|
||||
// go run ./scratch/healthproof
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
const (
|
||||
locationID = "1135"
|
||||
terminalID = "TPROOF" // throwaway; disappears on its own when the TTL lapses
|
||||
apiBase = "https://fiesta.nearle.app/live/api/v1/pos"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
brokerURL := os.Getenv("MQTT_URL")
|
||||
if brokerURL == "" {
|
||||
log.Fatal("MQTT_URL not set")
|
||||
}
|
||||
|
||||
opts := mqtt.NewClientOptions().
|
||||
AddBroker(brokerURL).
|
||||
SetClientID("healthproof-" + terminalID).
|
||||
SetUsername(os.Getenv("MQTT_USER")).
|
||||
SetPassword(os.Getenv("MQTT_PASSWORD")).
|
||||
SetConnectTimeout(10 * time.Second)
|
||||
|
||||
client := mqtt.NewClient(opts)
|
||||
if t := client.Connect(); t.Wait() && t.Error() != nil {
|
||||
log.Fatal("connect: ", t.Error())
|
||||
}
|
||||
defer client.Disconnect(250)
|
||||
fmt.Printf("connected to %s as %s\n\n", brokerURL, terminalID)
|
||||
|
||||
fmt.Println("BEFORE — has this terminal ever been seen?")
|
||||
show()
|
||||
|
||||
// Exactly what the till sends every 30 seconds.
|
||||
health, _ := json.Marshal(map[string]any{
|
||||
"schema": 1, "status": "online",
|
||||
"terminal_id": terminalID, "location_id": locationID,
|
||||
"store_name": "Ragul stores Selvapuram", "app_version": "1.1.0",
|
||||
"pending_bills": 0, "pending_registrations": 0,
|
||||
"today_bills": 17, "today_amount": 2510.0,
|
||||
"printer_reachable": true,
|
||||
"reported_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
|
||||
topic := fmt.Sprintf("nearle/pos/%s/%s/health", locationID, terminalID)
|
||||
if t := client.Publish(topic, 1, false, health); t.Wait() && t.Error() != nil {
|
||||
log.Fatal("publish: ", t.Error())
|
||||
}
|
||||
fmt.Printf("\npublished one heartbeat to %s\n", topic)
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
fmt.Println("\nAFTER one heartbeat:")
|
||||
show()
|
||||
|
||||
// The TTL is 90s and a real till refreshes every 30s, so it never lapses
|
||||
// while the till is alive. Stopping here is what a till being switched off
|
||||
// looks like.
|
||||
fmt.Println("\nnow going quiet, as a till that was switched off would.")
|
||||
fmt.Println("presence TTL is 90s, so it should drop off on its own:")
|
||||
|
||||
for _, wait := range []int{30, 30, 35} {
|
||||
time.Sleep(time.Duration(wait) * time.Second)
|
||||
fmt.Printf("\n+%ds since the last heartbeat:\n", wait)
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
func show() {
|
||||
resp, err := http.Get(fmt.Sprintf("%s/health/location?location_id=%s", apiBase, locationID))
|
||||
if err != nil {
|
||||
fmt.Println(" API unreachable:", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var out struct {
|
||||
Details struct {
|
||||
Online int `json:"online"`
|
||||
Total int `json:"total"`
|
||||
Terminals []struct {
|
||||
Terminalid string `json:"terminal_id"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
Todaybills int `json:"today_bills"`
|
||||
Todayamount float64 `json:"today_amount"`
|
||||
Reportedat string `json:"reported_at"`
|
||||
} `json:"terminals"`
|
||||
} `json:"details"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
fmt.Println(" unparseable:", string(body)[:200])
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" online %d of %d\n", out.Details.Online, out.Details.Total)
|
||||
for _, t := range out.Details.Terminals {
|
||||
mark := " "
|
||||
if t.Terminalid == terminalID {
|
||||
mark = ">"
|
||||
}
|
||||
extra := t.Reason
|
||||
if t.Status == "online" {
|
||||
extra = fmt.Sprintf("today %d bills / Rs %.0f, reported %s",
|
||||
t.Todaybills, t.Todayamount, t.Reportedat)
|
||||
}
|
||||
fmt.Printf(" %s %-8s %-8s %s\n", mark, t.Terminalid, t.Status, extra)
|
||||
}
|
||||
}
|
||||
194
scratch/seedprices/main.go
Normal file
194
scratch/seedprices/main.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Seed retail prices so the POS has something sellable.
|
||||
//
|
||||
// These are plausible Coimbatore figures, not authoritative ones. They exist so
|
||||
// the terminal can ring a real bill; the owner corrects them afterwards.
|
||||
//
|
||||
// go run ./scratch/seedprices plan # show every change and the undo SQL
|
||||
// go run ./scratch/seedprices apply # write them
|
||||
// go run ./scratch/seedprices verify # read back what the catalogue now serves
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type priced struct {
|
||||
locationID int
|
||||
productID int
|
||||
name string
|
||||
unit string
|
||||
price float64
|
||||
note string
|
||||
}
|
||||
|
||||
// Prices are per the product's own unit — per kilogram where the unit is
|
||||
// kilogram, per pack where it is piece. Getting that backwards is the easiest
|
||||
// way to make a till look broken, so the unit is carried through and printed.
|
||||
var seed = []priced{
|
||||
// 1135 — fresh produce
|
||||
{1135, 6988, "Mysore Banana", "kilogram", 60, ""},
|
||||
{1135, 6989, "Jammu Apple", "piece", 30, "per fruit, not per kg"},
|
||||
{1135, 6990, "Small orange", "kilogram", 90, ""},
|
||||
{1135, 6991, "Red Guava", "kilogram", 80, ""},
|
||||
{1135, 6992, "Pomegrante", "kilogram", 180, ""},
|
||||
{1135, 6993, "Salem Mango", "kilogram", 90, "seasonal, swings 80-120"},
|
||||
{1135, 6994, "Pineapple", "kilogram", 60, ""},
|
||||
{1135, 6995, "Strawberries", "piece", 150, "priced as a punnet"},
|
||||
{1135, 6996, "Maceral", "kilogram", 220, "READ AS MACKEREL - correct if wrong"},
|
||||
{1135, 6997, "Tuna", "kilogram", 280, ""},
|
||||
{1135, 6998, "Hatsun curd", "piece", 30, "500g pouch"},
|
||||
{1135, 7014, "Apple", "kilogram", 200, ""},
|
||||
|
||||
// 1185 — packaged
|
||||
{1185, 7074, "Amla Dabur Oral Care Chewing Gum 10g", "piece", 10, ""},
|
||||
{1185, 7075, "Cheetos Chips 100g", "piece", 40, ""},
|
||||
{1185, 7076, "Cheerios Breakfast Cereal 100g", "piece", 120, ""},
|
||||
// 7077 Hot Heads is already at 50 — someone set it deliberately, leave it.
|
||||
}
|
||||
|
||||
func main() {
|
||||
mode := "plan"
|
||||
if len(os.Args) > 1 {
|
||||
mode = os.Args[1]
|
||||
}
|
||||
|
||||
_ = godotenv.Load()
|
||||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
|
||||
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "plan":
|
||||
plan(db, false)
|
||||
case "apply":
|
||||
plan(db, true)
|
||||
case "verify":
|
||||
verify(db)
|
||||
default:
|
||||
log.Fatalf("unknown mode %q — use plan, apply or verify", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func plan(db *gorm.DB, write bool) {
|
||||
fmt.Printf("%-6s %-38s %-9s %8s -> %8s\n", "id", "product", "unit", "now", "new")
|
||||
fmt.Println("--------------------------------------------------------------------------------")
|
||||
|
||||
undo := []string{}
|
||||
changes := 0
|
||||
|
||||
for _, p := range seed {
|
||||
var current struct {
|
||||
Price float64
|
||||
Tenantid int
|
||||
Found bool
|
||||
}
|
||||
row := db.Raw(`SELECT COALESCE(price, 0) AS price, tenantid, true AS found
|
||||
FROM productlocations
|
||||
WHERE productid = ? AND locationid = ?
|
||||
LIMIT 1`, p.productID, p.locationID).Scan(¤t)
|
||||
if row.Error != nil {
|
||||
log.Fatalf("reading %d: %v", p.productID, row.Error)
|
||||
}
|
||||
if !current.Found {
|
||||
fmt.Printf("%-6d %-38s NO productlocations ROW - skipped\n", p.productID, p.name)
|
||||
continue
|
||||
}
|
||||
|
||||
// Never overwrite a price a human already set. A seed value is a
|
||||
// placeholder; a real one is a decision, and losing it silently would
|
||||
// be worse than leaving a gap.
|
||||
if current.Price > 0 {
|
||||
fmt.Printf("%-6d %-38s %-9s %8.2f already priced, left alone\n",
|
||||
p.productID, p.name, p.unit, current.Price)
|
||||
continue
|
||||
}
|
||||
|
||||
note := ""
|
||||
if p.note != "" {
|
||||
note = " <- " + p.note
|
||||
}
|
||||
fmt.Printf("%-6d %-38s %-9s %8.2f -> %8.2f%s\n",
|
||||
p.productID, p.name, p.unit, current.Price, p.price, note)
|
||||
|
||||
undo = append(undo, fmt.Sprintf(
|
||||
"UPDATE productlocations SET price = %.2f WHERE productid = %d AND locationid = %d;",
|
||||
current.Price, p.productID, p.locationID))
|
||||
changes++
|
||||
|
||||
if write {
|
||||
// updated is bumped so the catalogue delta carries the new price to
|
||||
// terminals that already hold a revision, rather than waiting for
|
||||
// someone to force a full pull.
|
||||
err := db.Exec(`UPDATE productlocations
|
||||
SET price = ?, updated = NOW()
|
||||
WHERE productid = ? AND locationid = ?`,
|
||||
p.price, p.productID, p.locationID).Error
|
||||
if err != nil {
|
||||
log.Fatalf("writing %d: %v", p.productID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("--------------------------------------------------------------------------------")
|
||||
if write {
|
||||
fmt.Printf("APPLIED %d price(s).\n\n", changes)
|
||||
} else {
|
||||
fmt.Printf("%d price(s) would change. Nothing written — run `apply` to commit.\n\n", changes)
|
||||
}
|
||||
|
||||
fmt.Println("-- undo, if you want the zeros back:")
|
||||
for _, u := range undo {
|
||||
fmt.Println(u)
|
||||
}
|
||||
}
|
||||
|
||||
func verify(db *gorm.DB) {
|
||||
type row struct {
|
||||
Locationid int
|
||||
Productid int
|
||||
Productname string
|
||||
Price float64
|
||||
Taxpercent float64
|
||||
}
|
||||
var rows []row
|
||||
db.Raw(`SELECT b.locationid, a.productid, a.productname,
|
||||
COALESCE(b.price, 0) AS price, COALESCE(a.taxpercent, 0) AS taxpercent
|
||||
FROM products a
|
||||
INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid
|
||||
WHERE b.locationid IN (1135, 1185) AND a.productid > 0
|
||||
ORDER BY b.locationid, a.productid`).Scan(&rows)
|
||||
|
||||
sellable := 0
|
||||
for _, r := range rows {
|
||||
flag := ""
|
||||
if r.Price > 0 {
|
||||
sellable++
|
||||
} else {
|
||||
flag = " <- still zero, not sellable"
|
||||
}
|
||||
fmt.Printf("loc %d %-6d %-38s %8.2f tax=%.0f%s\n",
|
||||
r.Locationid, r.Productid, r.Productname[:min(38, len(r.Productname))], r.Price, r.Taxpercent, flag)
|
||||
}
|
||||
fmt.Printf("\n%d of %d rows are sellable.\n", sellable, len(rows))
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
90
scratch/termfixcleanup/main.go
Normal file
90
scratch/termfixcleanup/main.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Removes the single bill posted to prove the terminalid fix on v1.3.96.
|
||||
//
|
||||
// Named by its own terminalorderid rather than by date or by "the newest row" —
|
||||
// pos_orders holds real takings, and is not a table to run an unbounded DELETE
|
||||
// against. Stock is returned before the bill is deleted, so the ledger is never
|
||||
// left short with nothing remaining to explain why.
|
||||
//
|
||||
// go run ./scratch/termfixcleanup
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
tenantID = 1087
|
||||
locationID = 1135
|
||||
testOrder = "a1b2c3d4-0000-4000-8000-termfix00001"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
|
||||
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var billIDs []int
|
||||
db.Raw(`SELECT posorderid FROM pos_orders WHERE terminalorderid = ?`,
|
||||
testOrder).Scan(&billIDs)
|
||||
|
||||
if len(billIDs) == 0 {
|
||||
fmt.Println("no test bill found — nothing to undo")
|
||||
return
|
||||
}
|
||||
fmt.Printf("found test bill(s): %v\n", billIDs)
|
||||
|
||||
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 rounds 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 unit(s) of product %d\n", qty, c.Productid)
|
||||
}
|
||||
|
||||
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 bill(s) and their items\n", len(billIDs))
|
||||
|
||||
var left int64
|
||||
db.Raw(`SELECT COUNT(*) FROM pos_orders WHERE terminalorderid = ?`, testOrder).Scan(&left)
|
||||
fmt.Printf("\nremaining test rows: %d\n", left)
|
||||
|
||||
var stock 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 productid = 6988 AND locationid = ? AND tenantid = ?`,
|
||||
locationID, tenantID).Scan(&stock)
|
||||
fmt.Printf("Mysore Banana stock now: %.0f (was 750 before any probe)\n", stock)
|
||||
}
|
||||
Reference in New Issue
Block a user