diff --git a/scratch/poslogins/main.go b/scratch/poslogins/main.go new file mode 100644 index 0000000..a2289ca --- /dev/null +++ b/scratch/poslogins/main.go @@ -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] + "…" +}