A till holds every bill in its own SQLite database and keeps it for seven days after we acknowledge it, marking one synced only when its id comes back in an ack. Everything here follows from that. Silence is not acceptance, so a failing ingest publishes nothing at all and the terminal simply sends again. A duplicate is a success, because at-least-once delivery means a lost ack legitimately re-delivers bills we already hold, and calling those failures would strand a day of takings on the till. Deduplication is a unique index on the terminal's UUID plus an advisory lock held for the transaction. Bills land in pos_orders / pos_order_items rather than orders: a counter bill carries a cashier, a terminal, a rounding adjustment, promos, loyalty movement and a payment split that orders has nowhere to put, and forcing one into the other loses whatever does not fit. Stock is *not* split — a counter sale writes the same productstocks rows an app order does, through helpers extracted from createOrderTx so the rule that prevents overselling has one implementation rather than two. GetRevenueSummary and GetSalesSummary were extended to union the new table in; any new report has to remember the same. Terminal health goes to Redis under a 90-second TTL, sharing the instance the express backend uses. A heartbeat is a fact with an expiry date: a till that loses power stops refreshing and ages off the board by itself, where a Postgres row would need ~288k writes a day and a reaper. Proven end to end against the live estate before commit: a bill over HTTP and one over the real Mosquitto broker, the same bill three times producing one row and one stock movement, and a heartbeat arriving on the health endpoint. All probe data was removed afterwards. Four things that only surfaced against real data. An unset jsonb column failed the very first bill. Product SKUs are unusable as barcodes — 6,245 products share 93 SKUs and "1" covers 5,794 of them — against the till's unique index, so barcodes fall back to the product id. A taxpercent of -1 exists and would have put negative GST in a filed slab. And a product with id 0 exists, which can never be billed and is now skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
196 lines
6.5 KiB
Go
196 lines
6.5 KiB
Go
package repositories
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
"nearle/db"
|
|
"nearle/models"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
// POS terminal presence, in Redis.
|
|
//
|
|
// ### Why Redis and not a table
|
|
//
|
|
// A heartbeat is a fact with an expiry date. Written to Postgres it needs a
|
|
// row per till updated twice a minute — around 288,000 writes a day across a
|
|
// hundred terminals — and a reaper job to mark a till offline once it stops,
|
|
// because a row that says "online" has no way of ageing out on its own.
|
|
//
|
|
// A Redis key with a TTL does the ageing for free. A till that loses power
|
|
// stops refreshing, the key expires, and it disappears from the board without
|
|
// anything having to notice. That is the whole design.
|
|
//
|
|
// ### Keys
|
|
//
|
|
// pos:terminal:{terminalcode} HASH, TTL 90s — one till's state
|
|
// pos:location:{locationid}:terminals SET, no TTL — which tills a shop has
|
|
//
|
|
// The set has no TTL on purpose, mirroring how `city:{tenantid}:active_deliveries`
|
|
// is treated in the express backend: it is an index of what exists, not a claim
|
|
// that any of it is alive right now. Membership means "this till has been seen
|
|
// here"; liveness is whether the hash still exists.
|
|
const (
|
|
// Three missed heartbeats. Two would make an ordinary GPRS hiccup look like
|
|
// a dead till; five would take two and a half minutes to notice a real one.
|
|
posPresenceTTL = 90 * time.Second
|
|
|
|
posTerminalKeyFmt = "pos:terminal:%s"
|
|
posLocationKeyFmt = "pos:location:%s:terminals"
|
|
)
|
|
|
|
type PosPresenceRepository interface {
|
|
Record(ctx context.Context, health models.PosHealth) error
|
|
Terminal(ctx context.Context, terminalID string) (map[string]string, error)
|
|
Location(ctx context.Context, locationID string) ([]map[string]string, error)
|
|
}
|
|
|
|
type posPresenceRepository struct{}
|
|
|
|
func NewPosPresenceRepository() PosPresenceRepository { return &posPresenceRepository{} }
|
|
|
|
// Record writes one heartbeat and refreshes its TTL.
|
|
func (r *posPresenceRepository) Record(ctx context.Context, health models.PosHealth) error {
|
|
if db.Rdb == nil {
|
|
return fmt.Errorf("redis is not configured")
|
|
}
|
|
if health.Terminalid == "" {
|
|
return fmt.Errorf("heartbeat has no terminal id")
|
|
}
|
|
|
|
terminalKey := fmt.Sprintf(posTerminalKeyFmt, health.Terminalid)
|
|
|
|
fields := map[string]any{
|
|
"terminal_id": health.Terminalid,
|
|
"location_id": health.Locationid,
|
|
"store_name": health.Storename,
|
|
"app_version": health.Appversion,
|
|
"status": health.Status,
|
|
|
|
"pending_bills": health.Pendingbills,
|
|
"pending_registrations": health.Pendingregistrations,
|
|
"oldest_pending_at": health.Oldestpendingat,
|
|
|
|
"today_bills": health.Todaybills,
|
|
"today_amount": health.Todayamount,
|
|
"last_bill_at": health.Lastbillat,
|
|
|
|
"reported_at": health.Reportedat,
|
|
// Stamped here as well as at the till. The two disagreeing by more than
|
|
// a few seconds means the terminal's clock is wrong — which matters,
|
|
// because bills are filed under the business date the till decided.
|
|
"received_at": time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
|
|
// Device readings only when the till actually reported them. A build that
|
|
// does not collect battery level must not leave one behind saying 0%.
|
|
if health.Batterylevel != nil {
|
|
fields["battery_level"] = *health.Batterylevel
|
|
}
|
|
if health.Batterycharging != nil {
|
|
fields["battery_charging"] = *health.Batterycharging
|
|
}
|
|
if health.Storagefreemb != nil {
|
|
fields["storage_free_mb"] = *health.Storagefreemb
|
|
}
|
|
if health.Printerreachable != nil {
|
|
fields["printer_reachable"] = *health.Printerreachable
|
|
}
|
|
if health.Drawerstatus != nil {
|
|
fields["drawer_status"] = *health.Drawerstatus
|
|
}
|
|
|
|
// HSet leaves untouched fields in place, so a reading that stops being
|
|
// reported would otherwise linger for ever at its last value. Clearing the
|
|
// absent ones keeps the hash honest about what this till currently knows.
|
|
stale := make([]string, 0, 5)
|
|
for field, reported := range map[string]bool{
|
|
"battery_level": health.Batterylevel != nil,
|
|
"battery_charging": health.Batterycharging != nil,
|
|
"storage_free_mb": health.Storagefreemb != nil,
|
|
"printer_reachable": health.Printerreachable != nil,
|
|
"drawer_status": health.Drawerstatus != nil,
|
|
} {
|
|
if !reported {
|
|
stale = append(stale, field)
|
|
}
|
|
}
|
|
|
|
pipe := db.Rdb.TxPipeline()
|
|
pipe.HSet(ctx, terminalKey, fields)
|
|
if len(stale) > 0 {
|
|
pipe.HDel(ctx, terminalKey, stale...)
|
|
}
|
|
pipe.Expire(ctx, terminalKey, posPresenceTTL)
|
|
|
|
if health.Locationid != "" {
|
|
// No TTL: this is the list of tills a shop has, not a claim that any of
|
|
// them is alive. Liveness is whether the hash above still exists.
|
|
pipe.SAdd(ctx, fmt.Sprintf(posLocationKeyFmt, health.Locationid), health.Terminalid)
|
|
}
|
|
|
|
_, err := pipe.Exec(ctx)
|
|
return err
|
|
}
|
|
|
|
// Terminal returns one till's last known state, or nil if it has gone quiet.
|
|
func (r *posPresenceRepository) Terminal(ctx context.Context, terminalID string) (map[string]string, error) {
|
|
if db.Rdb == nil {
|
|
return nil, fmt.Errorf("redis is not configured")
|
|
}
|
|
|
|
fields, err := db.Rdb.HGetAll(ctx, fmt.Sprintf(posTerminalKeyFmt, terminalID)).Result()
|
|
if err != nil && err != redis.Nil {
|
|
return nil, err
|
|
}
|
|
if len(fields) == 0 {
|
|
// Expired or never seen. Both mean "not reporting", which is what the
|
|
// caller needs to know; distinguishing them would need a durable record
|
|
// this deliberately does not keep.
|
|
return nil, nil
|
|
}
|
|
return fields, nil
|
|
}
|
|
|
|
// Location returns every till registered at a shop, live or dark.
|
|
//
|
|
// A till whose key has expired comes back as a stub with status "offline"
|
|
// rather than being omitted. Omitting it would make a dead terminal
|
|
// indistinguishable from one that was never installed — and the dead one is
|
|
// precisely what somebody is looking for.
|
|
func (r *posPresenceRepository) Location(ctx context.Context, locationID string) ([]map[string]string, error) {
|
|
if db.Rdb == nil {
|
|
return nil, fmt.Errorf("redis is not configured")
|
|
}
|
|
|
|
members, err := db.Rdb.SMembers(ctx, fmt.Sprintf(posLocationKeyFmt, locationID)).Result()
|
|
if err != nil && err != redis.Nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]map[string]string, 0, len(members))
|
|
for _, terminalID := range members {
|
|
fields, err := r.Terminal(ctx, terminalID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if fields == nil {
|
|
fields = map[string]string{
|
|
"terminal_id": terminalID,
|
|
"location_id": locationID,
|
|
"status": "offline",
|
|
// Says why it is being reported offline, rather than leaving a
|
|
// reader to guess whether the till said so or simply vanished.
|
|
"reason": "no heartbeat within " + strconv.Itoa(int(posPresenceTTL.Seconds())) + "s",
|
|
}
|
|
}
|
|
out = append(out, fields)
|
|
}
|
|
|
|
return out, nil
|
|
}
|