Compare commits

...

4 Commits

Author SHA1 Message Date
Suriya
5864204d32 Zero the produce rates, on the owner's instruction
The eight fresh lines at 1135 held 8, 12 and 18. Fresh unbranded fruit and
chilled fish are nil-rated under Indian GST, so those were overcharging.

Held back on the first pass and reported as REVIEW, because every one is a
reduction of a live tax rate and that is a decision for whoever signs the
returns rather than something a script should quietly do. Put to the owner and
released explicitly.

Two readings are assumed and are worth checking against what the counter
actually sells. Maceral and Tuna are taken as fresh or chilled — frozen,
branded or packaged fish is 5%. Hatsun curd was already 0 and stays there as
plain curd; flavoured yoghurt would be 5%.

The undo SQL for all eight is in the tool's output and restores the previous
rates exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:51:56 +05:30
Suriya
d0c3cb751e Document the sale-date contract, and rate the packaged goods
parsePosSaleDate needed no change — RFC3339Nano already accepts the offset the
terminal now sends, and Format("2006-01-02") on a zoned time still yields the
till's own trading day rather than UTC's. But the ordering of those layouts is
load-bearing and nothing said so, and the two bare layouts are a legacy that
should be recognisable as one: they exist for terminals built before the offset,
whose bills record an instant wrong by the offset with nothing in the payload to
recover it from. Two tests pin both halves, including the case that motivated
this — 00:30 IST, where UTC has not yet rolled into the same day.

The GST script writes only the four packaged lines at 1185, which sat at 0 and
were being billed with no tax at all.

It deliberately does not touch the produce at 1135. That was written believing
every rate was 0 — read from a field name that does not exist in the response,
so the check silently returned nothing. The rows in fact hold 8, 12 and 18, and
under Indian GST fresh unbranded fruit and chilled fish are nil-rated, so
several look like overcharging. Every correction there is a reduction of a live
rate, which belongs to whoever signs the returns rather than to a script. They
are reported as REVIEW and left as found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:18:17 +05:30
Suriya
11595ad415 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>
2026-08-05 18:48:24 +05:30
Suriya
ec672a3087 Add an HTTP heartbeat, and file bills under the till that rang them
Two faults found while checking whether today's live bills had landed. They
had — 17 of them, complete — but both of these were sitting in the same data.

**Health existed only over MQTT.** The consumer subscribes to the health topic
and has done since startup, but a terminal on the HTTP route has no way to
reach it. Today's terminal was on HTTP, so it reported nothing and the board
showed "online 0 of 1" while the till was demonstrably alive and selling.
POST /pos/health now takes the same payload the broker carries, into the same
Redis record, so the board cannot tell the two routes apart and does not need
to. It answers 202 and swallows failures: a till that cannot say how it is must
still sell.

**terminalid was empty on 16 of 17 bills.** The consumer backfills a missing
terminal code from the topic, but onto the batch, while the row was built from
the order — the two never met, and importPosOrder was not handed the batch's
value at all. Over HTTP there was no topic to fall back on either. So the
invoice numbers read INV-2608-T5EDD-000NN while the column they should have
matched was blank, and `byterminal` on the sales summary grouped almost
everything under "". The bill's own terminal now wins with the batch's as the
fallback, trimmed, so whitespace is not mistaken for a code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:48:24 +05:30
8 changed files with 795 additions and 2 deletions

View File

@@ -95,6 +95,63 @@ func (ctl *PosController) IngestCustomers(c *fiber.Ctx) error {
return c.Status(http.StatusOK).JSON(ack) return c.Status(http.StatusOK).JSON(ack)
} }
// IngestHealth records one heartbeat from a till.
//
// The same heartbeat the broker carries, over HTTP, because presence was
// previously reachable *only* over MQTT — a terminal configured for the HTTP
// route reported bills perfectly and never appeared on the fleet board at all,
// with nothing anywhere to say why. A monitoring feature that silently does not
// exist on one of two supported transports is worse than no feature.
//
// Answers 202 rather than 200: nothing is committed, and the till is told not
// to wait on it. Failures are swallowed for the same reason the MQTT path
// swallows them — a terminal that cannot say how it is must still sell, and a
// blank square on a dashboard beats a till that stopped because Redis was busy.
func (ctl *PosController) IngestHealth(c *fiber.Ctx) error {
var health models.PosHealth
if err := c.BodyParser(&health); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "could not read the heartbeat: " + err.Error(),
"status": false,
})
}
// Over MQTT these come from the topic. There is no topic here, so the body
// is the only source and both are required — a heartbeat that cannot say
// which till it belongs to is unfilable.
if strings.TrimSpace(health.Terminalid) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false,
"message": "terminal_id is required",
})
}
if strings.TrimSpace(health.Locationid) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false,
"message": "location_id is required",
})
}
// Matches the consumer: a bare {"status":"offline"} is a Last Will and must
// survive as-is, but an unset status from a till that is plainly talking to
// us means online.
if strings.TrimSpace(health.Status) == "" {
health.Status = "online"
}
if err := ctl.posService.RecordHealth(c.Context(), health); err != nil {
// Logged, not returned. See above — the till must not slow down for it.
log.Printf("pos: could not record heartbeat from %s/%s over HTTP: %v",
health.Locationid, health.Terminalid, err)
}
return c.Status(http.StatusAccepted).JSON(fiber.Map{
"status": true, "code": http.StatusAccepted,
})
}
// Catalogue answers a terminal's product pull. // Catalogue answers a terminal's product pull.
func (ctl *PosController) Catalogue(c *fiber.Ctx) error { func (ctl *PosController) Catalogue(c *fiber.Ctx) error {
storeID := strings.TrimSpace(c.Query("store_id")) storeID := strings.TrimSpace(c.Query("store_id"))

View File

@@ -112,7 +112,7 @@ func (r *posRepository) IngestOrders(batch models.PosOrderBatch) (*models.PosAck
ack.Reject("", "order is missing its id") ack.Reject("", "order is missing its id")
continue continue
} }
if reason := r.importPosOrder(ctx, products, batch.Batchid, order); reason != "" { if reason := r.importPosOrder(ctx, products, batch.Batchid, batch.Terminalid, order); reason != "" {
ack.Reject(order.Id, reason) ack.Reject(order.Id, reason)
continue continue
} }
@@ -132,10 +132,15 @@ func (r *posRepository) IngestOrders(batch models.PosOrderBatch) (*models.PosAck
// in opposite directions and both matter: the bill is its own kind of document // in opposite directions and both matter: the bill is its own kind of document
// and deserves its own table, but stock is one number per shelf and must not be // and deserves its own table, but stock is one number per shelf and must not be
// tracked twice. // tracked twice.
// batchTerminal is the terminal the whole batch came from, used when a bill
// does not name one itself. Over MQTT the consumer fills it in from the topic;
// over HTTP the terminal sends it once at the top of the batch rather than
// repeating it on every bill.
func (r *posRepository) importPosOrder( func (r *posRepository) importPosOrder(
ctx *offlineLocationContext, ctx *offlineLocationContext,
products map[int]offlineProduct, products map[int]offlineProduct,
batchID string, batchID string,
batchTerminal string,
order models.PosOrder, order models.PosOrder,
) string { ) string {
if len(order.Items) == 0 { if len(order.Items) == 0 {
@@ -302,7 +307,11 @@ func (r *posRepository) importPosOrder(
Invoicenumber: order.Invoicenumber, Invoicenumber: order.Invoicenumber,
Tenantid: ctx.Tenantid, Tenantid: ctx.Tenantid,
Locationid: ctx.Locationid, Locationid: ctx.Locationid,
Terminalid: order.Terminalid, // The bill's own terminal wins; the batch's is the fallback. Without
// this the column was empty on every bill that arrived over HTTP —
// the invoice number carried the code and the column did not, so
// per-terminal reconciliation had nothing to group on.
Terminalid: posTerminalFor(order.Terminalid, batchTerminal),
Cashiername: order.Cashier, Cashiername: order.Cashier,
Customerid: customerID, Customerid: customerID,
Customermobile: posCustomerMobile(order), Customermobile: posCustomerMobile(order),
@@ -372,6 +381,18 @@ func posJSON(v any) string {
return string(body) return string(body)
} }
// posTerminalFor picks which terminal code to file a bill under.
//
// Trimmed before the emptiness test: a terminal sending `" "` is saying nothing,
// and treating that as a real code would file bills under a blank that looks
// identical to the missing value this exists to fix.
func posTerminalFor(orderTerminal, batchTerminal string) string {
if t := strings.TrimSpace(orderTerminal); t != "" {
return t
}
return strings.TrimSpace(batchTerminal)
}
func posCustomerName(order models.PosOrder) string { func posCustomerName(order models.PosOrder) string {
if order.Customer == nil { if order.Customer == nil {
return "" return ""
@@ -391,6 +412,20 @@ func posCustomerMobile(order models.PosOrder) string {
// The terminal sends ISO-8601. A blank one falls back to now; an unparseable // The terminal sends ISO-8601. A blank one falls back to now; an unparseable
// one is refused, because importing a sale under the wrong date corrupts every // one is refused, because importing a sale under the wrong date corrupts every
// daily revenue figure that reads it. // daily revenue figure that reads it.
// parsePosSaleDate reads the moment a bill was rung.
//
// The order of these layouts is load-bearing, and the two zoned ones must stay
// first. A terminal that sends its offset — `2026-08-05T00:30:00+05:30` — gets
// both readings right: the instant is correct, and Format("2006-01-02") still
// yields the till's own trading day rather than UTC's.
//
// The two bare layouts exist for terminals built before the offset was added,
// which are still in the field. `time.Parse` fills an absent zone with UTC, so
// those bills record an instant wrong by the offset — a Coimbatore wall clock
// read as though it were London. That is not recoverable here: nothing in the
// payload says which zone it came from. Their business date is still right,
// which is why the daily figures held up while billedat did not, and why these
// are tolerated rather than refused.
func parsePosSaleDate(raw string) (time.Time, error) { func parsePosSaleDate(raw string) (time.Time, error) {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {

View File

@@ -160,3 +160,94 @@ func TestAnOutletCannotReplayAnotherOutletsRevision(t *testing.T) {
} }
} }
} }
// The bug this covers reached production and stayed invisible for a day.
//
// The MQTT consumer backfills a missing terminal code from the topic, but it
// wrote it onto the *batch* while the row was built from the *order*, so the
// two never met. Bills arriving over HTTP had no topic to fall back on at all.
// The result: 16 of 17 live bills carried an empty terminalid while their own
// invoice numbers read INV-2608-T5EDD-000NN, and `byterminal` on the sales
// summary grouped almost everything under "".
func TestABillTakesItsTerminalFromTheBatchWhenItNamesNone(t *testing.T) {
cases := []struct {
name string
orderTerminal string
batchTerminal string
want string
}{
{"bill names its own", "T5EDD", "TOTHER", "T5EDD"},
{"bill is silent, batch knows", "", "T5EDD", "T5EDD"},
{"neither knows", "", "", ""},
// Whitespace is not a terminal code. Treating it as one would file
// bills under a blank that reads identically to the missing value
// this fallback exists to prevent.
{"bill sends whitespace", " ", "T5EDD", "T5EDD"},
{"batch sends whitespace", "", " ", ""},
{"codes are trimmed", " T5EDD ", "", "T5EDD"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := posTerminalFor(c.orderTerminal, c.batchTerminal); got != c.want {
t.Errorf("posTerminalFor(%q, %q) = %q, want %q",
c.orderTerminal, c.batchTerminal, got, c.want)
}
})
}
}
// billedat and businessdate are derived from the same parsed value and pull in
// opposite directions, so they are tested together.
//
// Live bill INV-2608-T5EDD-00116 carried billedat 2026-08-05T12:49:28Z beside
// receivedat 2026-08-05T07:19:28Z — the sale appearing to happen five and a
// half hours after it was received. The till was sending a naive local
// timestamp and time.Parse fills that silence with UTC, so a Coimbatore wall
// clock was recorded as though read in London.
//
// The daily figures survived it by luck: businessdate comes off the wall clock
// either way, and the wall clock was always the till's own. Anything comparing
// billedat against real time did not.
func TestASaleDateKeepsBothTheInstantAndTheTradingDay(t *testing.T) {
// Coimbatore, late enough that UTC has not yet rolled into the same day.
const ist = "2026-08-05T00:30:00+05:30"
at, err := parsePosSaleDate(ist)
if err != nil {
t.Fatalf("parsePosSaleDate(%q) errored: %v", ist, err)
}
// The instant. 00:30 IST is 19:00 UTC the previous evening.
wantInstant := time.Date(2026, 8, 4, 19, 0, 0, 0, time.UTC)
if !at.UTC().Equal(wantInstant) {
t.Errorf("instant = %v, want %v", at.UTC(), wantInstant)
}
// The trading day. This is the one that must NOT follow UTC — the shop rang
// this sale on the 5th and its takings belong to the 5th. Deriving the
// business date from UTC would file it under the 4th and leave two days
// wrong: one short, one over.
if got := at.Format("2006-01-02"); got != "2026-08-05" {
t.Errorf("businessdate = %s, want 2026-08-05 — the till's own day", got)
}
}
// Terminals built before the offset was added send a bare local timestamp, and
// they are still in the field. Parsing must not start refusing them.
//
// The instant such a bill records is wrong by the offset and cannot be
// recovered — there is nothing in the payload that says which zone it was read
// in. Its business date is still right, which is why the daily figures held up,
// and why this stays a tolerated legacy rather than a rejection.
func TestANaiveSaleDateIsStillAccepted(t *testing.T) {
at, err := parsePosSaleDate("2026-08-05T12:49:28.245")
if err != nil {
t.Fatalf("a pre-offset terminal must not be refused: %v", err)
}
if got := at.Format("2006-01-02"); got != "2026-08-05" {
t.Errorf("businessdate = %s, want 2026-08-05", got)
}
}

View File

@@ -23,6 +23,11 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
pos.Post("/customers", f.PosController.IngestCustomers) pos.Post("/customers", f.PosController.IngestCustomers)
pos.Get("/catalogue", f.PosController.Catalogue) pos.Get("/catalogue", f.PosController.Catalogue)
// The 30-second heartbeat, for tills on the HTTP route. The broker carries
// the same payload for tills on MQTT; both land in the same Redis record,
// so the fleet board cannot tell them apart and does not need to.
pos.Post("/health", f.PosController.IngestHealth)
// Counter sales, read back out. The ingest above only ever writes; without // Counter sales, read back out. The ingest above only ever writes; without
// these a committed bill is unreachable from every screen in the product. // these a committed bill is unreachable from every screen in the product.
pos.Get("/sales", f.PosController.GetSales) pos.Get("/sales", f.PosController.GetSales)

180
scratch/gstrates/main.go Normal file
View File

@@ -0,0 +1,180 @@
// Set GST rates on the POS catalogue products.
//
// The four packaged lines at 1185 sit at taxpercent 0 and are being billed with
// no GST at all — a live compliance problem rather than a cosmetic one. Those
// are written.
//
// The produce at 1135 is NOT all zero, which is what this was first written
// believing. It holds 8, 12 and 18, and under Indian GST fresh unbranded fruit
// and chilled fish are nil-rated — so several look like overcharging. Every
// correction there is a *reduction* of a live rate, which is a decision for
// whoever signs the returns. Reported as REVIEW and left untouched.
//
// go run ./scratch/gstrates plan
// go run ./scratch/gstrates apply
package main
import (
"fmt"
"log"
"os"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// Indian GST on food, as it applies to these lines.
//
// Fresh, unbranded and unpackaged produce is nil-rated, which is why the fruit
// stays at 0 rather than being "not set yet". Packaged branded snacks are 12%.
// Breakfast cereal is 18%.
//
// Fish is the one worth stating: fresh or chilled is nil-rated, and only
// frozen/branded/packaged attracts 5%. Left at 0 on the reading that a counter
// selling loose Mysore bananas is selling fresh fish, not frozen packs.
type rate struct {
productID int
name string
percent float64
why string
// apply gates the write. Only rows that are unambiguously *unset* are
// written; anything already carrying a rate is reported and left alone.
//
// The fresh produce at 1135 is the reason for this flag. Those rows are not
// blank — they hold 8, 12 and 18 — and under Indian GST fresh unbranded
// fruit and chilled fish are nil-rated, so several look like overcharging.
// But *lowering* a live tax rate is a compliance decision belonging to
// whoever signs the returns, not a bug to be quietly corrected by a script,
// and someone is actively working on pricing in this repo. Reported, not
// touched.
apply bool
}
var rates = []rate{
// 1135 — fresh produce, nil-rated under Indian GST.
//
// These were held back at first because every one is a *reduction* of a
// live rate, which is a compliance decision rather than a bug fix. Released
// on the owner's explicit instruction after that was put to them.
//
// Two readings are assumed and should be checked against what the counter
// actually sells: Maceral and Tuna are taken as fresh or chilled, which is
// nil-rated — frozen, branded or packaged fish is 5%. Hatsun curd is taken
// as plain curd, which is nil-rated — flavoured yoghurt is 5%.
{6988, "Mysore Banana", 0, "fresh fruit — nil-rated, currently 8%", true},
{6989, "Jammu Apple", 0, "fresh fruit — nil-rated, currently 18%", true},
{6990, "Small orange", 0, "fresh fruit — nil-rated, currently 18%", true},
{6991, "Red Guava", 0, "fresh fruit — nil-rated, currently 18%", true},
{6992, "Pomegrante", 0, "fresh fruit — nil-rated, currently 12%", true},
{6993, "Salem Mango", 0, "fresh fruit — nil-rated", true},
{6994, "Pineapple", 0, "fresh fruit — nil-rated", true},
{6995, "Strawberries", 0, "fresh fruit — nil-rated, currently 18%", true},
{6996, "Maceral", 0, "fresh fish nil-rated; 5% only if frozen/packaged", true},
{6997, "Tuna", 0, "fresh fish nil-rated; 5% only if frozen/packaged", true},
{6998, "Hatsun curd", 0, "curd nil-rated; flavoured yoghurt would be 5%", true},
{7014, "Apple", 0, "fresh fruit — nil-rated", true},
// 1185 — genuinely unset, and being billed with no GST at all today. This
// is the half that is unambiguous: every one is an increase from zero, so
// nothing is being under-collected on the strength of a script's opinion.
{7074, "Amla Dabur Oral Care Chewing Gum 10g", 18, "chewing gum, 18%", true},
{7075, "Cheetos Chips 100g", 12, "packaged extruded snack, 12%", true},
{7076, "Cheerios Breakfast Cereal 100g", 18, "packaged cereal, 18%", true},
{7077, "Hot Heads 30g", 12, "packaged snack, 12%", true},
}
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)
}
write := mode == "apply"
fmt.Printf("%-6s %-38s %6s -> %6s %s\n", "id", "product", "now", "new", "why")
fmt.Println("-------------------------------------------------------------------------------------------")
undo := []string{}
changes := 0
review := 0
for _, r := range rates {
var current struct {
Taxpercent float64
Found bool
}
if err := db.Raw(`SELECT COALESCE(taxpercent, 0) AS taxpercent, true AS found
FROM products WHERE productid = ? LIMIT 1`,
r.productID).Scan(&current).Error; err != nil {
log.Fatalf("reading %d: %v", r.productID, err)
}
if !current.Found {
fmt.Printf("%-6d %-38s NO products ROW - skipped\n", r.productID, r.name)
continue
}
if current.Taxpercent == r.percent {
fmt.Printf("%-6d %-38s %6.0f unchanged %s\n",
r.productID, r.name, current.Taxpercent, r.why)
continue
}
if !r.apply {
fmt.Printf("%-6d %-38s %6.0f REVIEW %-3.0f %s\n",
r.productID, r.name, current.Taxpercent, r.percent, r.why)
review++
continue
}
fmt.Printf("%-6d %-38s %6.0f -> %6.0f %s\n",
r.productID, r.name, current.Taxpercent, r.percent, r.why)
undo = append(undo, fmt.Sprintf(
"UPDATE products SET taxpercent = %.0f WHERE productid = %d;",
current.Taxpercent, r.productID))
changes++
if write {
// updated is bumped so the catalogue delta carries the new rate to
// terminals holding a revision, rather than waiting for a full pull.
if err := db.Exec(`UPDATE products SET taxpercent = ?, updated = NOW()
WHERE productid = ?`, r.percent, r.productID).Error; err != nil {
log.Fatalf("writing %d: %v", r.productID, err)
}
}
}
fmt.Println("-------------------------------------------------------------------------------------------")
if write {
fmt.Printf("APPLIED %d rate(s).\n", changes)
} else {
fmt.Printf("%d rate(s) would change. Nothing written — run `apply` to commit.\n", changes)
}
if review > 0 {
fmt.Printf("%d row(s) flagged REVIEW and deliberately not written — each is a\n"+
"reduction of a live tax rate and needs a decision, not a script.\n", review)
}
fmt.Println()
if len(undo) > 0 {
fmt.Println("-- undo:")
for _, u := range undo {
fmt.Println(u)
}
}
}

141
scratch/healthproof/main.go Normal file
View 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
View 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(&current)
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
}

View 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)
}