diff --git a/scratch/healthproof/main.go b/scratch/healthproof/main.go new file mode 100644 index 0000000..f87192a --- /dev/null +++ b/scratch/healthproof/main.go @@ -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) + } +} diff --git a/scratch/seedprices/main.go b/scratch/seedprices/main.go new file mode 100644 index 0000000..066721c --- /dev/null +++ b/scratch/seedprices/main.go @@ -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 +} diff --git a/scratch/termfixcleanup/main.go b/scratch/termfixcleanup/main.go new file mode 100644 index 0000000..066a9e2 --- /dev/null +++ b/scratch/termfixcleanup/main.go @@ -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) +}