Compare commits
4 Commits
bddd8fa265
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5864204d32 | ||
|
|
d0c3cb751e | ||
|
|
11595ad415 | ||
|
|
ec672a3087 |
@@ -95,6 +95,63 @@ func (ctl *PosController) IngestCustomers(c *fiber.Ctx) error {
|
||||
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.
|
||||
func (ctl *PosController) Catalogue(c *fiber.Ctx) error {
|
||||
storeID := strings.TrimSpace(c.Query("store_id"))
|
||||
|
||||
@@ -112,7 +112,7 @@ func (r *posRepository) IngestOrders(batch models.PosOrderBatch) (*models.PosAck
|
||||
ack.Reject("", "order is missing its id")
|
||||
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)
|
||||
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
|
||||
// and deserves its own table, but stock is one number per shelf and must not be
|
||||
// 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(
|
||||
ctx *offlineLocationContext,
|
||||
products map[int]offlineProduct,
|
||||
batchID string,
|
||||
batchTerminal string,
|
||||
order models.PosOrder,
|
||||
) string {
|
||||
if len(order.Items) == 0 {
|
||||
@@ -302,7 +307,11 @@ func (r *posRepository) importPosOrder(
|
||||
Invoicenumber: order.Invoicenumber,
|
||||
Tenantid: ctx.Tenantid,
|
||||
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,
|
||||
Customerid: customerID,
|
||||
Customermobile: posCustomerMobile(order),
|
||||
@@ -372,6 +381,18 @@ func posJSON(v any) string {
|
||||
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 {
|
||||
if order.Customer == nil {
|
||||
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
|
||||
// one is refused, because importing a sale under the wrong date corrupts every
|
||||
// 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) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
|
||||
pos.Post("/customers", f.PosController.IngestCustomers)
|
||||
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
|
||||
// these a committed bill is unreachable from every screen in the product.
|
||||
pos.Get("/sales", f.PosController.GetSales)
|
||||
|
||||
180
scratch/gstrates/main.go
Normal file
180
scratch/gstrates/main.go
Normal 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(¤t).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
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