Add a read-only report of who can actually open a till at an outlet

Answers the question a shop asks on day one and nothing in the product could:
what do I type into the terminal. It separates the two credentials because they
are not interchangeable — a password opens a closed terminal and the account
decides which shell it opens, a PIN only switches operator on one already open —
and it prints the shell each account would land in rather than the raw roleid,
since roleid 0 is not in app_roles and reads as nothing at all.

`top` ranks outlets by products actually stocked *and* filters to ones somebody
can sign in to. That filter is the point: the best-stocked outlets on the
platform — Dilse at 471 products, Ninhao at 286 — have no account with a
password, so a demo pointed at either opens a till nobody can unlock.

Counts products through productlocations rather than per tenant, because a
tenant with a full catalogue can still have an outlet stocking none of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-08-07 11:26:10 +05:30
parent cd2459dbb6
commit f5e16b54cc

214
scratch/poslogins/main.go Normal file
View File

@@ -0,0 +1,214 @@
// Reports who can actually sign in at an outlet, and by which of the two ways.
//
// Read-only. Answers the question a shop asks on day one — "what do I type into
// the till?" — by separating the two credentials that exist, because they are
// not interchangeable:
//
// - a password opens a *closed* terminal, and only a supervisor's does
// anything useful, because the shell it opens is decided by the account;
//
// - a PIN switches operator on a terminal that is *already open*, and is
// useless on its own.
//
// go run ./scratch/poslogins 1087 1135
package main
import (
"fmt"
"log"
"os"
"strconv"
"strings"
"nearle/models"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type row struct {
Userid int
Fullname, Authname, Contactno string
Password string
Pin int64
Roleid, Configid int
Status string
}
func main() {
tenantID, locationID := 1087, 1135
if len(os.Args) > 2 {
tenantID, _ = strconv.Atoi(os.Args[1])
locationID, _ = strconv.Atoi(os.Args[2])
}
_ = 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)
}
// `top` ranks outlets by what a till would actually have to sell, so a demo
// is pointed at a shop with a catalogue rather than at one that opens empty.
if len(os.Args) > 1 && os.Args[1] == "top" {
type outletRow struct {
Tenantid, Locationid int
Tenantname, Locname string
Products, Withpasswd int
}
var top []outletRow
db.Raw(`
SELECT t.tenantid, l.locationid,
COALESCE(t.tenantname,'') AS tenantname,
COALESCE(l.locationname,'') AS locname,
COUNT(DISTINCT a.productid) AS products,
(SELECT COUNT(*) FROM app_users u
WHERE u.tenantid = t.tenantid
AND COALESCE(u.locationid,0) IN (l.locationid, 0)
AND COALESCE(u.password,'') <> ''
AND LOWER(COALESCE(u.status,'')) <> 'inactive') AS withpasswd
FROM tenantlocations l
JOIN tenants t ON t.tenantid = l.tenantid
JOIN productlocations b ON b.locationid = l.locationid AND b.tenantid = l.tenantid
JOIN products a ON a.productid = b.productid AND a.tenantid = b.tenantid
GROUP BY t.tenantid, l.locationid, t.tenantname, l.locationname
ORDER BY products DESC LIMIT 400`).Scan(&top)
fmt.Printf("%-8s %-10s %-22s %-26s %-9s %s\n",
"tenant", "outlet", "tenant name", "outlet name", "products", "can sign in")
shown := 0
for _, o := range top {
// Only outlets a person can actually open. A big catalogue behind a
// till nobody can sign in to is not a candidate for anything.
if o.Withpasswd == 0 || shown >= 12 {
continue
}
shown++
fmt.Printf("%-8d %-10d %-22s %-26s %-9d %d\n",
o.Tenantid, o.Locationid, trunc(o.Tenantname, 22),
trunc(o.Locname, 26), o.Products, o.Withpasswd)
}
return
}
var tenant, outlet string
db.Raw(`SELECT COALESCE(tenantname,'') FROM tenants WHERE tenantid=?`, tenantID).Scan(&tenant)
db.Raw(`SELECT COALESCE(locationname,'') FROM tenantlocations WHERE locationid=? AND tenantid=?`,
locationID, tenantID).Scan(&outlet)
if outlet == "" {
log.Fatalf("tenant %d has no outlet %d", tenantID, locationID)
}
// Counted the way /pos/catalogue counts them — products joined to this
// outlet — rather than per tenant. A tenant with a full catalogue can still
// have an outlet that stocks none of it, and that outlet's till opens empty.
var products int64
db.Raw(`SELECT COUNT(*)
FROM products a
INNER JOIN productlocations b
ON a.productid = b.productid AND a.tenantid = b.tenantid
WHERE a.tenantid = ? AND b.locationid = ?`, tenantID, locationID).Scan(&products)
fmt.Printf("tenant %d %s\noutlet %d %s\nproducts stocked at this outlet: %d\n",
tenantID, tenant, locationID, outlet, products)
// Everyone the outlet can see. locationid 0 is a tenant-wide account — a
// proprietor who is not pinned to one shop — and those can open any of
// their outlets, so they belong in this list too.
var rows []row
db.Raw(`
SELECT userid,
TRIM(COALESCE(firstname,'') || ' ' || COALESCE(lastname,'')) AS fullname,
COALESCE(authname,'') AS authname,
COALESCE(contactno,'') AS contactno,
COALESCE(password,'') AS password,
COALESCE(pin,0) AS pin,
COALESCE(roleid,0) AS roleid,
COALESCE(configid,0) AS configid,
COALESCE(status,'') AS status
FROM app_users
WHERE tenantid = ?
AND COALESCE(locationid,0) IN (?, 0)
AND LOWER(COALESCE(status,'')) <> 'inactive'
ORDER BY userid`, tenantID, locationID).Scan(&rows)
fmt.Printf("\n=== PASSWORD SIGN-IN (POST /v1/pos/login) — opens a closed terminal ===\n")
fmt.Printf("%-7s %-24s %-30s %-12s %-6s %s\n",
"userid", "name", "authname (the username)", "role", "shell", "password")
any := false
for _, r := range rows {
if strings.TrimSpace(r.Password) == "" {
continue
}
any = true
shell := "cashier"
if models.PosRoleCanManageStaff(r.Roleid) {
shell = "SUPER"
}
role := models.PosRoleName(r.Roleid)
if role == "" {
role = fmt.Sprintf("(roleid %d)", r.Roleid)
}
id := r.Authname
if id == "" {
id = r.Contactno + " (phone)"
}
fmt.Printf("%-7d %-24s %-30s %-12s %-6s %s\n",
r.Userid, trunc(r.Fullname, 24), trunc(id, 30), role, shell, r.Password)
}
if !any {
fmt.Println(" (nobody at this outlet has a password — the till cannot be opened)")
}
fmt.Printf("\n=== PIN SIGN-IN (POST /v1/pos/login/pin) — switches operator, terminal already open ===\n")
fmt.Printf("%-7s %-24s %-12s %-6s %s\n", "userid", "name", "role", "shell", "pin")
any = false
seen := map[int64]int{}
for _, r := range rows {
if r.Pin == 0 {
continue
}
seen[r.Pin]++
}
for _, r := range rows {
if r.Pin == 0 {
continue
}
any = true
shell := "cashier"
if models.PosRoleCanManageStaff(r.Roleid) {
shell = "SUPER"
}
role := models.PosRoleName(r.Roleid)
if role == "" {
role = fmt.Sprintf("(roleid %d)", r.Roleid)
}
note := ""
if seen[r.Pin] > 1 {
note = " <- DUPLICATE, refused at sign-in"
}
if r.Pin < 1000 {
note = " <- under 4 digits, cannot be typed"
}
fmt.Printf("%-7d %-24s %-12s %-6s %04d%s\n",
r.Userid, trunc(r.Fullname, 24), role, shell, r.Pin, note)
}
if !any {
fmt.Println(" (nobody at this outlet has a PIN)")
}
fmt.Println()
}
func trunc(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n-1] + "…"
}