Compare commits
2 Commits
bddd8fa265
...
11595ad415
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 ""
|
||||
|
||||
@@ -160,3 +160,40 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
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