Scope /health/terminal to the caller's tenant, and backfill the blank codes
The outlet check could not go in the middleware with the others: it scopes a request by the location it names, and this route names only a terminal code — free text minted at the till. The outlet is not known until after the lookup, so the check happens in the handler, reading the outlet off the heartbeat itself rather than off the request. Guessing "T4A9" now answers 403 instead of another shop's pending bills, takings so far today, and app version. Silent when no token is presented, in step with middleware.PosAuth: while POS_AUTH_REQUIRED is off, real tills are still calling this unauthenticated and refusing them would blank the fleet board for exactly the terminals it watches. scratch/termbackfill repairs the rows left behind by the posTerminalFor bug. The code was never lost — it is the third segment of the invoice number the till printed in the same transaction — so this derives rather than guesses, and refuses to write a code that outlet has never filed a bill under. 39 of 40 recovered; the holdout is a synthetic proof bill whose only sibling is itself, and unverifiable codes stay blank rather than becoming plausible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -181,6 +181,12 @@ func (ctl *PosController) Catalogue(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// TerminalHealth returns one till's live state, for a support call that starts
|
// TerminalHealth returns one till's live state, for a support call that starts
|
||||||
// with a terminal code.
|
// with a terminal code.
|
||||||
|
//
|
||||||
|
// The outlet check cannot live in the middleware like every other POS route's
|
||||||
|
// does. The middleware scopes a request by the location it *names*, and this
|
||||||
|
// request names none — only a terminal code, which is free text minted at the
|
||||||
|
// till and belongs to whichever shop is holding that device. So the outlet is
|
||||||
|
// not known until after the lookup, and the check has to happen here.
|
||||||
func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error {
|
func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error {
|
||||||
terminalID := strings.TrimSpace(c.Query("terminal_id"))
|
terminalID := strings.TrimSpace(c.Query("terminal_id"))
|
||||||
if terminalID == "" {
|
if terminalID == "" {
|
||||||
@@ -200,6 +206,11 @@ func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error {
|
|||||||
// Not an error. The till has simply not reported inside its TTL, which
|
// Not an error. The till has simply not reported inside its TTL, which
|
||||||
// is the answer the caller wanted — said plainly rather than as a 404
|
// is the answer the caller wanted — said plainly rather than as a 404
|
||||||
// that reads like the terminal does not exist.
|
// that reads like the terminal does not exist.
|
||||||
|
//
|
||||||
|
// Answered without an outlet check, and safely so: there is nothing to
|
||||||
|
// check against and nothing to leak. "Offline" is the same answer for a
|
||||||
|
// terminal code that was never issued, so guessing codes reveals only
|
||||||
|
// that guessing does not work.
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"code": http.StatusOK,
|
"code": http.StatusOK,
|
||||||
"status": true,
|
"status": true,
|
||||||
@@ -211,9 +222,54 @@ func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ctl.posTerminalInScope(c, fields); err != nil {
|
||||||
|
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
||||||
|
"code": http.StatusForbidden, "status": false, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": fields})
|
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": fields})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// posTerminalInScope refuses a heartbeat belonging to somebody else's shop.
|
||||||
|
//
|
||||||
|
// Reads the outlet off the heartbeat itself, because that — not the request —
|
||||||
|
// is the authority on which shop a terminal code belongs to. A caller who
|
||||||
|
// guesses "T4A9" gets a 403 rather than another shop's pending-bill count,
|
||||||
|
// takings so far today, and app version.
|
||||||
|
//
|
||||||
|
// Silent when the request carries no token, matching middleware.PosAuth: while
|
||||||
|
// POS_AUTH_REQUIRED is off, tills in the field are still calling these routes
|
||||||
|
// unauthenticated, and refusing them here would take the fleet board down for
|
||||||
|
// exactly the terminals it exists to watch. Once the flag is on, an untokened
|
||||||
|
// request never reaches this handler.
|
||||||
|
func (ctl *PosController) posTerminalInScope(c *fiber.Ctx, fields map[string]string) error {
|
||||||
|
claims, ok := middleware.PosClaimsFrom(c)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
locationID, err := strconv.Atoi(strings.TrimSpace(fields["location_id"]))
|
||||||
|
if err != nil || locationID <= 0 {
|
||||||
|
// A heartbeat that cannot say where it came from cannot be shown to a
|
||||||
|
// caller who must be scoped. Refusing beats guessing.
|
||||||
|
return fmt.Errorf("this terminal's outlet could not be determined")
|
||||||
|
}
|
||||||
|
|
||||||
|
if locationID == claims.Locationid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed, err := ctl.posService.LocationAllowed(claims.Tenantid, locationID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not verify outlet access")
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
return fmt.Errorf("this terminal belongs to an outlet this session cannot reach")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// LocationHealth returns every till at a shop — the "which counters are dark"
|
// LocationHealth returns every till at a shop — the "which counters are dark"
|
||||||
// board. Tills that have stopped reporting come back marked offline rather than
|
// board. Tills that have stopped reporting come back marked offline rather than
|
||||||
// being omitted, because a missing till is exactly what somebody is looking for.
|
// being omitted, because a missing till is exactly what somebody is looking for.
|
||||||
|
|||||||
64
scratch/termaudit/main.go
Normal file
64
scratch/termaudit/main.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// Read-only audit of terminalid coverage on pos_orders.
|
||||||
|
//
|
||||||
|
// Answers one question: are blank terminal codes still arriving, or do they all
|
||||||
|
// predate the posTerminalFor fix? Touches nothing.
|
||||||
|
//
|
||||||
|
// go run ./scratch/termaudit
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 rows []struct {
|
||||||
|
Day string
|
||||||
|
Blank int
|
||||||
|
Named int
|
||||||
|
Invoice string
|
||||||
|
}
|
||||||
|
db.Raw(`SELECT to_char(receivedat,'YYYY-MM-DD') AS day,
|
||||||
|
COUNT(*) FILTER (WHERE COALESCE(terminalid,'') = '') AS blank,
|
||||||
|
COUNT(*) FILTER (WHERE COALESCE(terminalid,'') <> '') AS named,
|
||||||
|
MIN(invoicenumber) AS invoice
|
||||||
|
FROM pos_orders GROUP BY 1 ORDER BY 1`).Scan(&rows)
|
||||||
|
|
||||||
|
fmt.Printf("%-12s %7s %7s %s\n", "received", "blank", "named", "sample invoice")
|
||||||
|
for _, r := range rows {
|
||||||
|
fmt.Printf("%-12s %7d %7d %s\n", r.Day, r.Blank, r.Named, r.Invoice)
|
||||||
|
}
|
||||||
|
|
||||||
|
var last struct {
|
||||||
|
Invoicenumber string
|
||||||
|
Terminalid string
|
||||||
|
Receivedat string
|
||||||
|
}
|
||||||
|
db.Raw(`SELECT invoicenumber, COALESCE(terminalid,'') AS terminalid,
|
||||||
|
to_char(receivedat,'YYYY-MM-DD HH24:MI') AS receivedat
|
||||||
|
FROM pos_orders WHERE COALESCE(terminalid,'') = ''
|
||||||
|
ORDER BY receivedat DESC LIMIT 1`).Scan(&last)
|
||||||
|
fmt.Printf("\nnewest blank bill: %s received %s\n", last.Invoicenumber, last.Receivedat)
|
||||||
|
|
||||||
|
// Does the invoice number itself carry the terminal code? INV-2608-T5EDD-00116
|
||||||
|
var recoverable int
|
||||||
|
db.Raw(`SELECT COUNT(*) FROM pos_orders
|
||||||
|
WHERE COALESCE(terminalid,'') = ''
|
||||||
|
AND invoicenumber ~ '^INV-[0-9]+-[A-Z0-9]+-[0-9]+$'`).Scan(&recoverable)
|
||||||
|
fmt.Printf("blank bills whose invoice number encodes a terminal: %d\n", recoverable)
|
||||||
|
}
|
||||||
132
scratch/termbackfill/main.go
Normal file
132
scratch/termbackfill/main.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
// Backfills terminalid on bills that arrived before the posTerminalFor fix.
|
||||||
|
//
|
||||||
|
// Between the till and v1.3.96 the ingest wrote the fallback terminal code onto
|
||||||
|
// the batch while the row was built from the order, so bills landed with an
|
||||||
|
// empty terminalid and `byterminal` grouped them all under "". The code was
|
||||||
|
// never lost — it is the third segment of the invoice number the till printed,
|
||||||
|
// INV-2608-T5EDD-00116, minted at the same terminal in the same transaction.
|
||||||
|
// So this derives rather than guesses.
|
||||||
|
//
|
||||||
|
// Only rows whose terminalid is blank AND whose invoice number matches the
|
||||||
|
// exact shape are touched; anything else is left alone and reported.
|
||||||
|
//
|
||||||
|
// go run ./scratch/termbackfill # dry run — shows every change
|
||||||
|
// go run ./scratch/termbackfill apply # writes, then prints the undo
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Anchored, and the terminal segment is required to be non-empty. A loose
|
||||||
|
// pattern here would write a blank over a blank and report it as a fix.
|
||||||
|
const invoiceShape = `^INV-[0-9]+-[A-Za-z0-9]+-[0-9]+$`
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
Posorderid int
|
||||||
|
Invoicenumber string
|
||||||
|
Locationid int
|
||||||
|
Derived string
|
||||||
|
Businessdate string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
apply := len(os.Args) > 1 && os.Args[1] == "apply"
|
||||||
|
|
||||||
|
_ = 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 blank int
|
||||||
|
db.Raw(`SELECT COUNT(*) FROM pos_orders WHERE COALESCE(terminalid,'') = ''`).Scan(&blank)
|
||||||
|
|
||||||
|
var rows []row
|
||||||
|
db.Raw(`SELECT posorderid, invoicenumber, locationid, businessdate,
|
||||||
|
split_part(invoicenumber, '-', 3) AS derived
|
||||||
|
FROM pos_orders
|
||||||
|
WHERE COALESCE(terminalid,'') = '' AND invoicenumber ~ ?
|
||||||
|
ORDER BY posorderid`, invoiceShape).Scan(&rows)
|
||||||
|
|
||||||
|
fmt.Printf("bills with a blank terminalid: %d\n", blank)
|
||||||
|
fmt.Printf("of those, recoverable from the invoice number: %d\n\n", len(rows))
|
||||||
|
if len(rows) != blank {
|
||||||
|
fmt.Printf("!! %d bill(s) cannot be recovered and will be left blank\n\n", blank-len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A derived code must already be in use at the same outlet, or it is not a
|
||||||
|
// terminal — it is a segment that happened to parse. A code that fails is
|
||||||
|
// skipped, not fatal: it means one till's bills are *all* blank, which no
|
||||||
|
// amount of cross-referencing can confirm from this table alone. Writing it
|
||||||
|
// anyway would launder a guess into the ledger as though it were recovered.
|
||||||
|
counts := map[string]int{}
|
||||||
|
for _, r := range rows {
|
||||||
|
counts[fmt.Sprintf("%d/%s", r.Locationid, r.Derived)]++
|
||||||
|
}
|
||||||
|
trusted := map[string]bool{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
|
||||||
|
fmt.Printf("%-10s %-9s %-6s %-12s %s\n", "location", "terminal", "bills", "date", "known at this outlet?")
|
||||||
|
for _, r := range rows {
|
||||||
|
key := fmt.Sprintf("%d/%s", r.Locationid, r.Derived)
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
|
||||||
|
var known int
|
||||||
|
db.Raw(`SELECT COUNT(*) FROM pos_orders
|
||||||
|
WHERE locationid = ? AND terminalid = ?`, r.Locationid, r.Derived).Scan(&known)
|
||||||
|
trusted[key] = known > 0
|
||||||
|
|
||||||
|
verdict := fmt.Sprintf("yes — %d bill(s) already filed under it", known)
|
||||||
|
if known == 0 {
|
||||||
|
verdict = "NO — never seen here, SKIPPED"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-10d %-9s %-6d %-12s %s\n", r.Locationid, r.Derived, counts[key], r.Businessdate, verdict)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]int, 0, len(rows))
|
||||||
|
skipped := 0
|
||||||
|
for _, r := range rows {
|
||||||
|
if trusted[fmt.Sprintf("%d/%s", r.Locationid, r.Derived)] {
|
||||||
|
ids = append(ids, r.Posorderid)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
fmt.Printf("\nwill update %d bill(s); leaving %d blank\n", len(ids), skipped)
|
||||||
|
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !apply {
|
||||||
|
fmt.Println("dry run — nothing written. re-run with `apply` to write.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res := db.Exec(`UPDATE pos_orders
|
||||||
|
SET terminalid = split_part(invoicenumber, '-', 3)
|
||||||
|
WHERE posorderid IN ? AND COALESCE(terminalid,'') = ''`, ids)
|
||||||
|
if res.Error != nil {
|
||||||
|
log.Fatal(res.Error)
|
||||||
|
}
|
||||||
|
fmt.Printf("\nupdated %d bill(s)\n", res.RowsAffected)
|
||||||
|
|
||||||
|
var stillBlank int
|
||||||
|
db.Raw(`SELECT COUNT(*) FROM pos_orders WHERE COALESCE(terminalid,'') = ''`).Scan(&stillBlank)
|
||||||
|
fmt.Printf("bills still blank: %d\n", stillBlank)
|
||||||
|
|
||||||
|
fmt.Printf("\nundo:\n UPDATE pos_orders SET terminalid = '' WHERE posorderid IN (%v);\n", ids)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user