Compare commits
2 Commits
5d2e1fca9b
...
27fbbf0422
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27fbbf0422 | ||
|
|
e3459a0f1c |
321
POS_TERMINAL_INGEST.md
Normal file
321
POS_TERMINAL_INGEST.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# POS terminal ingest
|
||||
|
||||
How an in-store Nearle POS till reaches this backend. The terminal side of the
|
||||
contract is specified in the POS repository at `docs/sync-contract.md`; this
|
||||
covers what was built here and how to turn it on.
|
||||
|
||||
## What a terminal expects, and why it matters
|
||||
|
||||
A till stores every bill in its own SQLite database the moment a sale
|
||||
completes, and keeps it for **seven days after we acknowledge it**. It marks a
|
||||
bill synced if — and only if — the bill's id appears in the `accepted` list of
|
||||
our reply.
|
||||
|
||||
That single rule drives every decision below:
|
||||
|
||||
- **Silence is not acceptance.** No reply, an empty reply, a 200 with no body:
|
||||
all leave the bill on the till, and it is sent again. This is the correct
|
||||
behaviour when we are struggling, and it is why a failing ingest never
|
||||
acknowledges.
|
||||
- **A duplicate is a success.** Delivery is at-least-once. A lost ack makes a
|
||||
terminal re-send bills we already hold, and calling those failures would
|
||||
strand a day of takings. The ingest recognises them and accepts them without
|
||||
touching stock again.
|
||||
- **A rejection is a decision.** Naming an id in `rejected` stops the till
|
||||
retrying it and waits for a person. Right for "this bill is malformed", wrong
|
||||
for "the database is having a bad minute".
|
||||
|
||||
## Two ways in, one code path
|
||||
|
||||
Both transports call `services.PosService`, so a bill arriving over MQTT and
|
||||
one arriving over HTTP cannot diverge.
|
||||
|
||||
### Where a bill lands
|
||||
|
||||
Counter sales are written to **`pos_orders` / `pos_order_items`**, not to
|
||||
`orders`. A bill is a different document from an app order: it carries a
|
||||
cashier, a terminal, a rounding adjustment, promo campaigns, loyalty movement
|
||||
and a payment split across several tenders, none of which `orders` has anywhere
|
||||
to put. Forcing one into the other's shape loses whichever fields do not fit,
|
||||
silently.
|
||||
|
||||
**Stock is not separate.** A counter sale writes the same `productstocks`
|
||||
"out" rows an app order does, through the shared helpers in `stockLedger.go` —
|
||||
the same row locks, the same availability check, the same availability re-sync.
|
||||
Two stock ledgers would mean the catalogue pull sends a till figures that ignore
|
||||
the till's own trading, and it would oversell.
|
||||
|
||||
Existing revenue queries were extended to include `pos_orders`
|
||||
(`GetRevenueSummary`, `GetSalesSummary`), so dashboards do not understate a shop
|
||||
that runs a counter. **Any new report has to remember to do the same** — that is
|
||||
the standing cost of the split.
|
||||
|
||||
### HTTP
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/live/api/v1/pos/orders` | Completed bills |
|
||||
| `POST` | `/live/api/v1/pos/customers` | Shoppers registered at a till |
|
||||
| `GET` | `/live/api/v1/pos/catalogue` | Product pull. Query: `store_id`, `since`, `page`, `page_size` |
|
||||
|
||||
These answer with a **bare ack**, not the usual `{code, message, status}`
|
||||
envelope — the terminal reads `accepted` from the top level of the body:
|
||||
|
||||
```json
|
||||
{ "batch_id": "9f1c…", "accepted": ["order-uuid"], "rejected": {} }
|
||||
```
|
||||
|
||||
Status codes carry the rest of the contract:
|
||||
|
||||
- **200** — batch processed. Individual bills may still be refused; the ack says which.
|
||||
- **4xx** — the request is wrong (unknown outlet, bad store id). The till halts and shows a person.
|
||||
- **5xx** — outcome unknown. The till keeps everything and retries with backoff.
|
||||
|
||||
### MQTT
|
||||
|
||||
The broker is **Eclipse Mosquitto 2.1.2** at `66.116.225.226:1883`, shared with
|
||||
the rider app (`nearle/riders/#`) and the doormile project (`doormile/riders/#`).
|
||||
|
||||
There is **no NATS** in this deployment. NATS servers exist for other projects
|
||||
on other hosts, but their ports are closed from here and their configs carry no
|
||||
`mqtt {}` block, so they expose no MQTT gateway. The NATS consumer that briefly
|
||||
lived in this package has been deleted rather than left to rot.
|
||||
|
||||
| Env | Purpose |
|
||||
|---|---|
|
||||
| `MQTT_URL` | `tcp://66.116.225.226:1883`. **Unset disables MQTT ingest** — HTTP still works |
|
||||
| `MQTT_USER` / `MQTT_PASSWORD` | Broker credentials |
|
||||
| `MQTT_CLIENT_ID` | Defaults to `nearle-pos-ingest`. **Must be unique per replica** — a second connection with the same id evicts the first, and the two would fight in a loop |
|
||||
|
||||
| Topic | Direction |
|
||||
|---|---|
|
||||
| `nearle/pos/+/+/order` | till → us |
|
||||
| `nearle/pos/+/+/customer` | till → us |
|
||||
| `nearle/pos/+/+/health` | till → us, every 30s |
|
||||
| `nearle/pos/{loc}/{terminal}/ack` | us → till |
|
||||
| `nearle/pos/{loc}/catalogue` | us → every till at a shop |
|
||||
|
||||
Subscribed with `CleanSession(false)` and a stable client id, so a brief restart
|
||||
resumes rather than missing what arrived meanwhile. Re-subscribes on every
|
||||
reconnect, because a broker that did not persist the session would otherwise
|
||||
come back subscribed to nothing.
|
||||
|
||||
**Do not treat the broker as durable storage.** Two measured facts make that
|
||||
unsafe:
|
||||
|
||||
- `max_queued_messages` is at its default of **1000**. If this backend is down
|
||||
long enough for a hundred tills to exceed that, Mosquitto silently drops the
|
||||
overflow.
|
||||
- Mosquitto's `autosave_interval` defaults to **30 minutes**, so a hard kill can
|
||||
lose up to half an hour of persisted state.
|
||||
|
||||
Neither loses a bill, and that is the whole point of the acknowledgement design:
|
||||
a dropped message is simply never acked, so the terminal keeps its copy and
|
||||
sends it again. The broker is a transport, not a ledger.
|
||||
|
||||
**The store and terminal are read from the topic, never from the body.** A till
|
||||
that could name its own store in the payload could redirect another counter's
|
||||
acknowledgements.
|
||||
|
||||
## Configuring a terminal
|
||||
|
||||
Settings → Connectivity & sync → Configure.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Store ID | **The numeric `locationid`.** Not a name — the tenant is resolved from it |
|
||||
| Terminal name | Whatever staff call the till |
|
||||
| Transport | `HTTP` or `MQTT` |
|
||||
| Base URL (HTTP) | `https://your-host/live/api/v1/pos` |
|
||||
| Broker host / port (MQTT) | `66.116.225.226`, port `1883`, **TLS off** (8883 is not configured) |
|
||||
|
||||
`store_id` carrying the locationid is load-bearing: `resolvePosStore` looks the
|
||||
tenant up from it and refuses a location that is not registered. A terminal
|
||||
cannot name its tenant.
|
||||
|
||||
## Mapping decisions
|
||||
|
||||
Worth knowing before the first bill lands.
|
||||
|
||||
- **Idempotency** is a unique index on `pos_orders.terminalorderid` — the UUID
|
||||
minted at the till — plus a Postgres advisory lock held for the life of the
|
||||
transaction, so a redelivery arriving concurrently waits and then sees the
|
||||
committed row rather than racing past the check.
|
||||
- **`businessdate` is the day the sale was rung**, not the day it arrived. A
|
||||
till that was offline overnight uploads yesterday's bills this morning, and
|
||||
they belong to yesterday. Every daily figure keys on this.
|
||||
- **Line amounts are scaled onto the bill total.** The till sends each line at
|
||||
its pre-apportionment value while the header carries the total after
|
||||
bill-level discounts. Left alone the item rows would sum to the subtotal and
|
||||
every report that adds up lines would disagree with the one reading the
|
||||
header.
|
||||
- **Payment mode** is the largest tender on a split bill; the full split is
|
||||
kept verbatim in `paymentsjson` for drawer reconciliation.
|
||||
- **Fractional quantities round *up* for stock.** `productstocks.quantity` is an
|
||||
integer column, so 1.5 kg of onions cannot be recorded exactly. Rounding up
|
||||
never under-deducts, so recorded stock is never higher than the shelf. The app
|
||||
order path truncates instead (1.5 → 1), which under-deducts; that behaviour was
|
||||
left untouched rather than silently changed for live traffic. **Making the
|
||||
column numeric is the real fix.**
|
||||
- **Customers** match on `contactno` within the outlet's `applocationid`, so a
|
||||
shopper registered at a till and one who installed the app become one row.
|
||||
Registrations are **insert-if-absent** — never an update, so a profile
|
||||
corrected at head office is not reverted by a terminal replaying an old
|
||||
capture.
|
||||
- **Catalogue** answers `is_delta: false` and is therefore a full snapshot. The
|
||||
terminal withdraws every product a snapshot omits, so this must stay true
|
||||
while the query returns everything stocked at the outlet.
|
||||
- **Barcodes** come from `products.productsku` — there is no barcode column.
|
||||
Scanning at the till matches on it, so SKUs must be the scannable code for
|
||||
barcode scanning to work.
|
||||
|
||||
## Terminal health
|
||||
|
||||
Every till publishes a heartbeat to `nearle/pos/{loc}/{terminal}/health` every
|
||||
**30 seconds**. It is stored in Redis, never in Postgres.
|
||||
|
||||
| Env | Purpose |
|
||||
|---|---|
|
||||
| `REDIS_HOST` / `REDIS_PORT` | **Unset disables presence.** Point at the same Redis the express backend uses |
|
||||
| `REDIS_USER` / `REDIS_PASSWORD` / `REDIS_DB` | Defaults `default`, empty, `0` |
|
||||
|
||||
```
|
||||
pos:terminal:{terminalcode} HASH, TTL 90s
|
||||
pos:location:{locationid}:terminals SET, no TTL
|
||||
```
|
||||
|
||||
The TTL is the whole design. A heartbeat is a fact with an expiry date: a till
|
||||
that loses power stops refreshing, the key expires, and it disappears from the
|
||||
board with nothing having to notice. In Postgres this would need ~288,000 writes
|
||||
a day across a hundred tills *and* a reaper job, because a row saying "online"
|
||||
cannot age out by itself.
|
||||
|
||||
90 seconds is three missed beats. 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.
|
||||
|
||||
The set has **no TTL**, mirroring `city:{tenantid}:active_deliveries` in the
|
||||
express backend: it is an index of what exists, not a claim that any of it is
|
||||
alive. Membership means "this till has been seen here"; liveness is whether the
|
||||
hash still exists.
|
||||
|
||||
Keys are namespaced `pos:*` and do not collide with express's `delivery:*`,
|
||||
`city:*` or `rider_*`. **Worth keeping that way** — a shared datastore only stays
|
||||
safe while each writer's keys are obviously its own.
|
||||
|
||||
A heartbeat is **never acknowledged**. Presence is fire-and-forget: a till that
|
||||
stopped selling because a dashboard was busy would be a self-inflicted outage.
|
||||
|
||||
Read it back:
|
||||
|
||||
| Method | Path |
|
||||
|---|---|
|
||||
| `GET` | `/live/api/v1/pos/health/terminal?terminal_id=T4A9` |
|
||||
| `GET` | `/live/api/v1/pos/health/location?location_id=12` |
|
||||
|
||||
A till whose key has expired comes back marked `offline` rather than being
|
||||
omitted — omitting it would make a dead terminal indistinguishable from one that
|
||||
was never installed, and the dead one is exactly what somebody is looking for.
|
||||
|
||||
What a heartbeat carries: identity and app version; **queue depth**
|
||||
(`pending_bills`, `pending_registrations`, `oldest_pending_at`) — the numbers
|
||||
that make a silent sync failure visible; **today's trading** (`today_bills`,
|
||||
`today_amount`, `last_bill_at`) — a till that is connected but has rung nothing
|
||||
in three hours is usually a jammed printer or an absent cashier; and device
|
||||
state.
|
||||
|
||||
## Not built
|
||||
|
||||
- **Catalogue deltas.** Every pull is a full snapshot. Fine for a few hundred
|
||||
products, worth revisiting at a few thousand.
|
||||
- **Loyalty coming back down.** The uplink deliberately carries no points or
|
||||
spend — those belong to the bill stream, which is idempotent and sees every
|
||||
counter. Nothing yet computes them centrally and sends them to the tills, so
|
||||
a shopper's balance at a till is that till's view.
|
||||
- **Device authentication.** A terminal is trusted with a locationid. Signed
|
||||
device tokens are the obvious next step before this is exposed publicly.
|
||||
- **Battery and free storage in the heartbeat.** The reporter has a hook for
|
||||
them, but this build collects neither — they need platform packages a desktop
|
||||
build has no use for. Fields that are not collected are **omitted**, not sent
|
||||
as zero: a board showing every till at 0% battery is worse than one showing
|
||||
nothing.
|
||||
|
||||
## Broker hardening — before a hundred tills join
|
||||
|
||||
Measured on the live broker, not assumed. None of this is caused by the POS
|
||||
work; all of it gets worse the moment bills start flowing.
|
||||
|
||||
**There is no ACL file.** `allow_anonymous false` is set and auth is by password
|
||||
file, but with no `acl_file` every authenticated user is unrestricted on every
|
||||
topic. The rider app ships `admin` credentials **hardcoded in its APK**, so
|
||||
anyone who decompiles it today has full publish and subscribe over `nearle/#`
|
||||
*and* `doormile/#` — a second project's traffic. Adding POS puts every shop's
|
||||
takings behind the same credential.
|
||||
|
||||
A scoped account is two commands and a container restart:
|
||||
|
||||
```bash
|
||||
# A user for the tills, and one for this backend.
|
||||
mosquitto_passwd -b /mosquitto/config/passwd pos_terminal '<strong-unique-pw>'
|
||||
mosquitto_passwd -b /mosquitto/config/passwd pos_ingest '<different-pw>'
|
||||
```
|
||||
|
||||
```conf
|
||||
# /mosquitto/config/acl — then add `acl_file /mosquitto/config/acl` to mosquitto.conf
|
||||
|
||||
# Tills: publish their own traffic, read only their own acks and their shop's
|
||||
# catalogue. The %c substitution binds a client to its own topics, so one till
|
||||
# cannot read another's.
|
||||
user pos_terminal
|
||||
topic write nearle/pos/+/+/order
|
||||
topic write nearle/pos/+/+/customer
|
||||
topic write nearle/pos/+/+/status
|
||||
topic write nearle/pos/+/+/health
|
||||
topic read nearle/pos/+/+/ack
|
||||
topic read nearle/pos/+/+/command
|
||||
topic read nearle/pos/+/catalogue
|
||||
|
||||
# This backend: the mirror image.
|
||||
user pos_ingest
|
||||
topic read nearle/pos/+/+/order
|
||||
topic read nearle/pos/+/+/customer
|
||||
topic read nearle/pos/+/+/health
|
||||
topic read nearle/pos/+/+/status
|
||||
topic write nearle/pos/+/+/ack
|
||||
topic write nearle/pos/+/+/command
|
||||
topic write nearle/pos/+/catalogue
|
||||
|
||||
# Existing projects, scoped to what they already use.
|
||||
user admin
|
||||
topic readwrite nearle/riders/#
|
||||
topic readwrite doormile/#
|
||||
```
|
||||
|
||||
Tighten `pos_terminal` further with per-terminal credentials if you want one
|
||||
till unable to read another's acks at all; the pattern above trusts tills within
|
||||
the fleet but not outside it.
|
||||
|
||||
**There is no TLS.** Port 8883 is not configured and is closed. Rider GPS
|
||||
travels in the clear today; POS bills carry customer names and mobile numbers,
|
||||
which is a different category of exposure on a shared network. Adding a listener
|
||||
means certs plus republishing the port, i.e. recreating the container — worth
|
||||
doing before rollout rather than after.
|
||||
|
||||
**Two more, from the audit:**
|
||||
|
||||
- The broker password and the workolik NATS password differ only in
|
||||
capitalisation. Diverge them when creating the scoped users.
|
||||
- Confirm on the host whether the broker was started from the compose file or
|
||||
from a bare `docker run` before editing the compose file and expecting it to
|
||||
take effect — there is precedent in this estate for compose existing but not
|
||||
being the deploy path.
|
||||
|
||||
## Capacity
|
||||
|
||||
Current load, measured: **~0.9 msg/s inbound**, 5 connected clients, 112
|
||||
retained messages totalling 8 KB.
|
||||
|
||||
A hundred tills add roughly 3.3 msg/s steady (a 1 KB heartbeat each per 30s)
|
||||
plus bursts of up to ~50 KB when a sale batch goes up. That is 3–4× current
|
||||
traffic and well within what Mosquitto handles on any VPS. The broker will not
|
||||
be the bottleneck; Postgres write throughput on bill ingest is the thing to
|
||||
watch instead.
|
||||
219
controllers/posController.go
Normal file
219
controllers/posController.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"nearle/models"
|
||||
"nearle/services"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// HTTP face of the POS terminal ingest.
|
||||
//
|
||||
// These handlers break this codebase's house style in one respect, on purpose:
|
||||
// they answer with a bare ack rather than the usual
|
||||
// `{code, message, status, details}` envelope. The terminal reads `accepted`
|
||||
// from the top level of the body and marks a bill synced only if its id is
|
||||
// there — wrapping the ack would leave every till queueing for ever.
|
||||
//
|
||||
// The status code carries the other half of the contract:
|
||||
//
|
||||
// - **200** — the batch was processed. Individual bills may still have been
|
||||
// refused; the ack says which.
|
||||
// - **4xx** — the request itself is wrong (unreadable body, unknown outlet).
|
||||
// The terminal treats these as non-retryable and halts, so a person is
|
||||
// told rather than the broker hammered.
|
||||
// - **5xx** — the outcome is unknown. The terminal keeps every bill and
|
||||
// retries with backoff. This is the right answer when the database is
|
||||
// having a bad minute: *never* ack a batch that did not commit.
|
||||
type PosController struct {
|
||||
posService services.PosService
|
||||
}
|
||||
|
||||
func NewPosController(posService services.PosService) *PosController {
|
||||
return &PosController{posService: posService}
|
||||
}
|
||||
|
||||
// IngestOrders receives a batch of completed counter bills.
|
||||
func (ctl *PosController) IngestOrders(c *fiber.Ctx) error {
|
||||
var batch models.PosOrderBatch
|
||||
|
||||
if err := c.BodyParser(&batch); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "could not read the batch: " + err.Error(),
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
if strings.TrimSpace(batch.Storeid) == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "store_id is required",
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
ack, err := ctl.posService.IngestOrders(batch)
|
||||
if err != nil {
|
||||
return posIngestError(c, "IngestOrders", err)
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(ack)
|
||||
}
|
||||
|
||||
// IngestCustomers receives shoppers registered at a till.
|
||||
func (ctl *PosController) IngestCustomers(c *fiber.Ctx) error {
|
||||
var batch models.PosCustomerBatch
|
||||
|
||||
if err := c.BodyParser(&batch); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "could not read the batch: " + err.Error(),
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
if strings.TrimSpace(batch.Storeid) == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "store_id is required",
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
ack, err := ctl.posService.IngestCustomers(batch)
|
||||
if err != nil {
|
||||
return posIngestError(c, "IngestCustomers", err)
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(ack)
|
||||
}
|
||||
|
||||
// Catalogue answers a terminal's product pull.
|
||||
func (ctl *PosController) Catalogue(c *fiber.Ctx) error {
|
||||
storeID := strings.TrimSpace(c.Query("store_id"))
|
||||
if storeID == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "store_id is required",
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.Query("page", "0"))
|
||||
pageSize, _ := strconv.Atoi(c.Query("page_size", "500"))
|
||||
|
||||
result, err := ctl.posService.Catalogue(storeID, c.Query("since"), page, pageSize)
|
||||
if err != nil {
|
||||
return posIngestError(c, "Catalogue", err)
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(result)
|
||||
}
|
||||
|
||||
// TerminalHealth returns one till's live state, for a support call that starts
|
||||
// with a terminal code.
|
||||
func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error {
|
||||
terminalID := strings.TrimSpace(c.Query("terminal_id"))
|
||||
if terminalID == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "message": "terminal_id is required", "status": false,
|
||||
})
|
||||
}
|
||||
|
||||
fields, err := ctl.posService.TerminalHealth(c.Context(), terminalID)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
|
||||
"code": http.StatusServiceUnavailable, "message": err.Error(), "status": false,
|
||||
})
|
||||
}
|
||||
|
||||
if fields == nil {
|
||||
// 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
|
||||
// that reads like the terminal does not exist.
|
||||
return c.JSON(fiber.Map{
|
||||
"code": http.StatusOK,
|
||||
"status": true,
|
||||
"details": fiber.Map{
|
||||
"terminal_id": terminalID,
|
||||
"status": "offline",
|
||||
"reason": "no heartbeat received within the presence window",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": fields})
|
||||
}
|
||||
|
||||
// LocationHealth returns every till at a shop — the "which counters are dark"
|
||||
// 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.
|
||||
func (ctl *PosController) LocationHealth(c *fiber.Ctx) error {
|
||||
locationID := strings.TrimSpace(c.Query("location_id"))
|
||||
if locationID == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "message": "location_id is required", "status": false,
|
||||
})
|
||||
}
|
||||
|
||||
terminals, err := ctl.posService.LocationHealth(c.Context(), locationID)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
|
||||
"code": http.StatusServiceUnavailable, "message": err.Error(), "status": false,
|
||||
})
|
||||
}
|
||||
|
||||
online := 0
|
||||
for _, t := range terminals {
|
||||
if t["status"] == "online" {
|
||||
online++
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"code": http.StatusOK,
|
||||
"status": true,
|
||||
"details": fiber.Map{
|
||||
"location_id": locationID,
|
||||
"total": len(terminals),
|
||||
"online": online,
|
||||
"terminals": terminals,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// posIngestError decides whether the terminal should retry.
|
||||
//
|
||||
// The distinction matters more than the message does. A misconfigured store id
|
||||
// will be just as wrong on the next attempt, so it is reported as a 4xx and the
|
||||
// till halts and shows a person the reason. Anything else might succeed later,
|
||||
// so it is a 5xx and the bills stay queued.
|
||||
func posIngestError(c *fiber.Ctx, op string, err error) error {
|
||||
log.Printf("pos %s: %v", op, err)
|
||||
|
||||
message := err.Error()
|
||||
lower := strings.ToLower(message)
|
||||
|
||||
permanent := strings.Contains(lower, "is not a location id") ||
|
||||
strings.Contains(lower, "no outlet is registered") ||
|
||||
strings.Contains(lower, "does not belong to tenant") ||
|
||||
strings.Contains(lower, "has no products stocked") ||
|
||||
strings.Contains(lower, "no applocationid configured")
|
||||
|
||||
status := http.StatusInternalServerError
|
||||
if permanent {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
|
||||
return c.Status(status).JSON(fiber.Map{
|
||||
"code": status,
|
||||
"message": message,
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
85
db/redis.go
Normal file
85
db/redis.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Rdb is the shared Redis connection, or nil when Redis is not configured.
|
||||
//
|
||||
// Deliberately the *same* instance the express backend uses. POS presence is
|
||||
// read by the rider app, which talks to that backend, and a second Redis would
|
||||
// mean either cross-service HTTP calls on every board refresh or two copies of
|
||||
// the truth about which tills are alive.
|
||||
//
|
||||
// Key namespaces do not collide: express owns `delivery:*`, `city:*`,
|
||||
// `rider_*`; POS owns `pos:*`. Worth keeping that way — a shared datastore only
|
||||
// stays safe while each writer's keys are obviously its own.
|
||||
var Rdb *redis.Client
|
||||
|
||||
// RedisCtx is the background context for Redis calls made outside a request.
|
||||
var RedisCtx = context.Background()
|
||||
|
||||
// InitRedis connects if REDIS_HOST is set, and does nothing if it is not.
|
||||
//
|
||||
// Redis is optional here: without it the POS health board goes dark, but bills
|
||||
// still arrive and commit. That is the right failure — losing presence is an
|
||||
// inconvenience, losing a sale is not — so this never aborts startup.
|
||||
func InitRedis() {
|
||||
host := strings.TrimSpace(os.Getenv("REDIS_HOST"))
|
||||
if host == "" {
|
||||
log.Println("redis: REDIS_HOST not set, POS presence disabled")
|
||||
return
|
||||
}
|
||||
|
||||
port := getEnv("REDIS_PORT", "6379")
|
||||
dbIndex, err := strconv.Atoi(getEnv("REDIS_DB", "0"))
|
||||
if err != nil {
|
||||
dbIndex = 0
|
||||
}
|
||||
|
||||
Rdb = redis.NewClient(&redis.Options{
|
||||
Addr: host + ":" + port,
|
||||
Username: getEnv("REDIS_USER", "default"),
|
||||
Password: os.Getenv("REDIS_PASSWORD"),
|
||||
DB: dbIndex,
|
||||
|
||||
// Short on purpose. A degraded Redis must fail fast rather than tie up
|
||||
// a pooled connection for tens of seconds — the express backend learned
|
||||
// this the hard way, where a 10s x 3-retry config let one stuck call
|
||||
// hold a connection for ~35s and exhausted the pool under load.
|
||||
DialTimeout: 5 * time.Second,
|
||||
ReadTimeout: 3 * time.Second,
|
||||
WriteTimeout: 3 * time.Second,
|
||||
PoolTimeout: 4 * time.Second,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(RedisCtx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := Rdb.Ping(ctx).Err(); err != nil {
|
||||
// Logged, not fatal. A broker that cannot be reached is fatal because
|
||||
// bills would silently queue; Redis being down only costs the board.
|
||||
log.Printf("redis: could not reach %s:%s — POS presence will be unavailable: %v", host, port, err)
|
||||
Rdb = nil
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ Redis connected at %s:%s (db %d)", host, port, dbIndex)
|
||||
}
|
||||
|
||||
// CloseRedis releases the pool on shutdown.
|
||||
func CloseRedis() {
|
||||
if Rdb == nil {
|
||||
return
|
||||
}
|
||||
if err := Rdb.Close(); err != nil {
|
||||
log.Printf("redis: close failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -9,16 +9,21 @@ import (
|
||||
)
|
||||
|
||||
type Facade struct {
|
||||
UserController *controllers.UserController
|
||||
ProductController *controllers.ProductController
|
||||
OrderController *controllers.OrderController
|
||||
DeliveriesController *controllers.DeliveriesController
|
||||
UtilsController *controllers.UtilsController
|
||||
TenantController *controllers.TenantController
|
||||
PartnerController *controllers.PartnerController
|
||||
UserController *controllers.UserController
|
||||
ProductController *controllers.ProductController
|
||||
OrderController *controllers.OrderController
|
||||
DeliveriesController *controllers.DeliveriesController
|
||||
UtilsController *controllers.UtilsController
|
||||
TenantController *controllers.TenantController
|
||||
PartnerController *controllers.PartnerController
|
||||
CustomerController *controllers.CustomerController
|
||||
StockRequestController *controllers.StockRequestController
|
||||
CatalogueController *controllers.CatalogueController
|
||||
PosController *controllers.PosController
|
||||
|
||||
// Held so the NATS consumer can reach the ingest without going through
|
||||
// HTTP. Unexported: everything else should use the controller.
|
||||
posService services.PosService
|
||||
}
|
||||
|
||||
// NewFacade wires up modules against the main (nearledb) connection.
|
||||
@@ -79,16 +84,33 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
|
||||
stockRequestService := services.NewStockRequestService(stockRequestRepo, productService)
|
||||
stockRequestController := controllers.NewStockRequestController(stockRequestService)
|
||||
|
||||
// POS Module — ingest from the in-store terminals.
|
||||
//
|
||||
// Presence has no *gorm.DB: terminal health lives in Redis under a TTL, so
|
||||
// a till that loses power ages out of the board by itself instead of
|
||||
// leaving a Postgres row claiming it is online.
|
||||
posRepo := repositories.NewPosRepository(db)
|
||||
posPresence := repositories.NewPosPresenceRepository()
|
||||
posService := services.NewPosService(posRepo, posPresence)
|
||||
posController := controllers.NewPosController(posService)
|
||||
|
||||
return &Facade{
|
||||
UserController: userController,
|
||||
ProductController: productController,
|
||||
OrderController: orderController,
|
||||
DeliveriesController: deliveriesController,
|
||||
UtilsController: utilsController,
|
||||
TenantController: tenantController,
|
||||
UserController: userController,
|
||||
ProductController: productController,
|
||||
OrderController: orderController,
|
||||
DeliveriesController: deliveriesController,
|
||||
UtilsController: utilsController,
|
||||
TenantController: tenantController,
|
||||
PartnerController: partnerController,
|
||||
CustomerController: customerController,
|
||||
StockRequestController: stockRequestController,
|
||||
CatalogueController: catalogueController,
|
||||
PosController: posController,
|
||||
posService: posService,
|
||||
}
|
||||
}
|
||||
|
||||
// PosService exposes the ingest to callers outside the HTTP layer — the NATS
|
||||
// consumer runs the same code path a POST does, so a bill arriving over MQTT
|
||||
// and one arriving over HTTP cannot diverge.
|
||||
func (f *Facade) PosService() services.PosService { return f.posService }
|
||||
|
||||
41
go.mod
41
go.mod
@@ -2,9 +2,21 @@ module nearle
|
||||
|
||||
go 1.24
|
||||
|
||||
toolchain go1.24.0
|
||||
|
||||
require gorm.io/gorm v1.25.10
|
||||
require (
|
||||
firebase.google.com/go v3.13.0+incompatible
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.30
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.29
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.1
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0
|
||||
github.com/gofiber/fiber v1.14.6
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
golang.org/x/oauth2 v0.12.0
|
||||
google.golang.org/api v0.143.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.25.10
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.110.7 // indirect
|
||||
@@ -14,12 +26,8 @@ require (
|
||||
cloud.google.com/go/iam v1.1.1 // indirect
|
||||
cloud.google.com/go/longrunning v0.5.1 // indirect
|
||||
cloud.google.com/go/storage v1.30.1 // indirect
|
||||
firebase.google.com/go v3.13.0+incompatible // indirect
|
||||
github.com/andybalholm/brotli v1.0.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
|
||||
@@ -28,24 +36,24 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect
|
||||
github.com/aws/smithy-go v1.27.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.1 // indirect
|
||||
github.com/gofiber/fiber v1.14.6 // indirect
|
||||
github.com/gofiber/utils v0.0.10 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/google/go-cmp v0.5.9 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/s2a-go v0.1.7 // indirect
|
||||
github.com/google/uuid v1.4.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.1 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
|
||||
github.com/gorilla/schema v1.1.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@@ -53,14 +61,14 @@ require (
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/klauspost/compress v1.17.2 // indirect
|
||||
github.com/klauspost/compress v1.19.0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.3.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
@@ -72,15 +80,14 @@ require (
|
||||
github.com/valyala/fasthttp v1.50.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
||||
golang.org/x/net v0.21.0 // indirect
|
||||
golang.org/x/oauth2 v0.12.0 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||
google.golang.org/api v0.143.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb // indirect
|
||||
@@ -88,7 +95,6 @@ require (
|
||||
google.golang.org/grpc v1.58.2 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gorm.io/driver/postgres v1.6.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -99,5 +105,4 @@ require (
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/mysql v1.5.2
|
||||
)
|
||||
|
||||
61
go.sum
61
go.sum
@@ -93,7 +93,13 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 h1:RvfHDg+xvAeZ+5741vUEjpOVtYSI
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
|
||||
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
|
||||
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
@@ -105,6 +111,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
@@ -118,9 +128,6 @@ github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyT
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/gofiber/fiber v1.14.6 h1:QRUPvPmr8ijQuGo1MgupHBn8E+wW0IKqiOvIZPtV70o=
|
||||
github.com/gofiber/fiber v1.14.6/go.mod h1:Yw2ekF1YDPreO9V6TMYjynu94xRxZBdaa8X5HhHsjCM=
|
||||
github.com/gofiber/fiber/v2 v2.50.0 h1:ia0JaB+uw3GpNSCR5nvC5dsaxXjRU5OEu36aytx+zGw=
|
||||
@@ -170,11 +177,14 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
|
||||
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
@@ -200,6 +210,8 @@ github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qK
|
||||
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
||||
github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY=
|
||||
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
@@ -226,8 +238,10 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1
|
||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4=
|
||||
github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -257,12 +271,14 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ=
|
||||
github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
@@ -303,6 +319,8 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
@@ -311,6 +329,8 @@ go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
@@ -320,8 +340,6 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
@@ -393,10 +411,8 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -418,8 +434,6 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -461,8 +475,6 @@ golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
|
||||
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
@@ -474,8 +486,6 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -641,8 +651,8 @@ google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
@@ -650,13 +660,8 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
|
||||
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
|
||||
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
||||
33
main.go
33
main.go
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"nearle/db"
|
||||
"nearle/facade"
|
||||
"nearle/messaging"
|
||||
"nearle/models"
|
||||
"nearle/routes"
|
||||
"os"
|
||||
@@ -39,13 +40,36 @@ func main() {
|
||||
db.Connect()
|
||||
fmt.Println("✅ Database connections established!")
|
||||
|
||||
// Shared with the express backend. POS terminal presence lives here under a
|
||||
// TTL; optional, because losing the health board is an inconvenience and
|
||||
// losing a sale is not.
|
||||
db.InitRedis()
|
||||
|
||||
// Ensure schema is updated
|
||||
db.DB.AutoMigrate(&models.StockRequest{})
|
||||
|
||||
// Counter sales from the in-store terminals. Separate tables from `orders`
|
||||
// because a bill carries a cashier, a terminal, rounding, promos, loyalty
|
||||
// and a payment split that `orders` has nowhere to put.
|
||||
if err := db.DB.AutoMigrate(&models.PosOrders{}, &models.PosOrderItems{}); err != nil {
|
||||
log.Fatal("POS schema migration failed:", err)
|
||||
}
|
||||
|
||||
f := facade.NewFacade(db.DB, db.CatalogueDB)
|
||||
|
||||
routes.RegisterRoutes(app, f)
|
||||
|
||||
// POS terminals reach the ingest over MQTT when MQTT_URL is set, and over
|
||||
// HTTP otherwise. Both land on the same service, so a bill cannot behave
|
||||
// differently depending on how it arrived.
|
||||
//
|
||||
// A broker that is configured but unreachable is fatal on purpose: coming
|
||||
// up healthy while every till quietly queues is the worse failure.
|
||||
posMqtt, err := messaging.StartPosMqttConsumer(f.PosService())
|
||||
if err != nil {
|
||||
log.Fatal("POS MQTT consumer failed to start:", err)
|
||||
}
|
||||
|
||||
// Start server
|
||||
go func() {
|
||||
if err := app.Listen(":1122"); err != nil {
|
||||
@@ -53,7 +77,7 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
gracefulShutdown()
|
||||
gracefulShutdown(posMqtt)
|
||||
}
|
||||
|
||||
func selectDBMiddleware(c *fiber.Ctx) error {
|
||||
@@ -78,13 +102,18 @@ func selectDBMiddleware(c *fiber.Ctx) error {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
func gracefulShutdown() {
|
||||
func gracefulShutdown(posMqtt *messaging.PosMqttConsumer) {
|
||||
c := make(chan os.Signal, 1)
|
||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
<-c
|
||||
fmt.Println("\nShutting down gracefully...")
|
||||
|
||||
// Drained before anything else: a bill mid-commit still gets its ack, and
|
||||
// without one the terminal would hold it and send it again on restart.
|
||||
posMqtt.Close()
|
||||
db.CloseRedis()
|
||||
|
||||
// Normally: close db.DB_DEV and db.DB_LIVE
|
||||
// Example:
|
||||
// closeDB(db.DB_DEV)
|
||||
|
||||
269
messaging/posmqtt.go
Normal file
269
messaging/posmqtt.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nearle/models"
|
||||
"nearle/services"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
// Plain-MQTT ingest for the Nearle POS terminals.
|
||||
//
|
||||
// The sibling of posconsumer.go, and which one you want depends entirely on
|
||||
// what is listening on the other end:
|
||||
//
|
||||
// - **This file** talks MQTT to a broker like Mosquitto or EMQX — the kind
|
||||
// already running at the rider app's `66.116.225.226:1883`.
|
||||
// - **posconsumer.go** talks the NATS protocol to a NATS server, which
|
||||
// exposes MQTT through a gateway but speaks NATS itself on 4222.
|
||||
//
|
||||
// They are not interchangeable: a NATS client cannot connect to Mosquitto, and
|
||||
// an MQTT client cannot use NATS' native subjects. Both call the same
|
||||
// PosService, so whichever is running, a bill lands identically.
|
||||
//
|
||||
// Enabled with MQTT_URL. Both may run at once, which is what a migration
|
||||
// between brokers looks like.
|
||||
|
||||
const (
|
||||
// Namespaced under `nearle/` alongside the rider app's
|
||||
// `nearle/riders/{riderId}/...`, so one broker ACL rule covers each system
|
||||
// and it is obvious from a topic which one it belongs to.
|
||||
//
|
||||
// Wildcards for MQTT are `+` per level, where NATS uses `*`.
|
||||
topicOrders = "nearle/pos/+/+/order"
|
||||
topicCustomers = "nearle/pos/+/+/customer"
|
||||
topicHealth = "nearle/pos/+/+/health"
|
||||
)
|
||||
|
||||
type PosMqttConsumer struct {
|
||||
client mqtt.Client
|
||||
svc services.PosService
|
||||
}
|
||||
|
||||
// StartPosMqttConsumer connects and subscribes.
|
||||
//
|
||||
// Returns (nil, nil) when MQTT_URL is unset — a deployment without a broker is
|
||||
// supported, and the caller carries on with the HTTP endpoints.
|
||||
func StartPosMqttConsumer(svc services.PosService) (*PosMqttConsumer, error) {
|
||||
url := strings.TrimSpace(os.Getenv("MQTT_URL"))
|
||||
if url == "" {
|
||||
log.Println("pos: MQTT_URL not set, plain-MQTT ingest disabled")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
c := &PosMqttConsumer{svc: svc}
|
||||
|
||||
opts := mqtt.NewClientOptions().
|
||||
AddBroker(url).
|
||||
// Stable, so the broker resumes this session and redelivers anything
|
||||
// in flight rather than treating every restart as a new subscriber.
|
||||
SetClientID(getEnvDefault("MQTT_CLIENT_ID", "nearle-pos-ingest")).
|
||||
SetCleanSession(false).
|
||||
SetAutoReconnect(true).
|
||||
SetMaxReconnectInterval(30 * time.Second).
|
||||
SetKeepAlive(30 * time.Second).
|
||||
SetConnectionLostHandler(func(_ mqtt.Client, err error) {
|
||||
log.Printf("pos: MQTT connection lost: %v", err)
|
||||
})
|
||||
|
||||
if user := os.Getenv("MQTT_USER"); user != "" {
|
||||
opts.SetUsername(user).SetPassword(os.Getenv("MQTT_PASSWORD"))
|
||||
}
|
||||
|
||||
// Re-subscribed on every (re)connect rather than once at startup: with a
|
||||
// broker that did not persist the session, a reconnect would otherwise come
|
||||
// back silently subscribed to nothing.
|
||||
opts.SetOnConnectHandler(func(client mqtt.Client) {
|
||||
log.Printf("pos: connected to MQTT broker %s", url)
|
||||
for topic, handler := range map[string]mqtt.MessageHandler{
|
||||
topicOrders: c.handleOrders,
|
||||
topicCustomers: c.handleCustomers,
|
||||
topicHealth: c.handleHealth,
|
||||
} {
|
||||
if token := client.Subscribe(topic, 1, handler); token.Wait() && token.Error() != nil {
|
||||
log.Printf("pos: could not subscribe to %s: %v", topic, token.Error())
|
||||
continue
|
||||
}
|
||||
log.Printf("pos: subscribed to %s", topic)
|
||||
}
|
||||
})
|
||||
|
||||
client := mqtt.NewClient(opts)
|
||||
if token := client.Connect(); token.Wait() && token.Error() != nil {
|
||||
return nil, fmt.Errorf("could not connect to the MQTT broker at %s: %w", url, token.Error())
|
||||
}
|
||||
c.client = client
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *PosMqttConsumer) handleOrders(_ mqtt.Client, msg mqtt.Message) {
|
||||
var batch models.PosOrderBatch
|
||||
if err := json.Unmarshal(msg.Payload(), &batch); err != nil {
|
||||
// Dropped rather than retried: there is no batch id to answer with, and
|
||||
// the till will time out and re-send anyway.
|
||||
log.Printf("pos: discarding unreadable order batch on %s: %v", msg.Topic(), err)
|
||||
return
|
||||
}
|
||||
|
||||
store, terminal := topicIdentity(msg.Topic())
|
||||
if batch.Storeid == "" {
|
||||
batch.Storeid = store
|
||||
}
|
||||
if batch.Terminalid == "" {
|
||||
batch.Terminalid = terminal
|
||||
}
|
||||
|
||||
ack, err := c.svc.IngestOrders(batch)
|
||||
if err != nil {
|
||||
// Nothing committed, so nothing is acknowledged. The terminal keeps
|
||||
// every bill and retries — which is the entire point of the design.
|
||||
log.Printf("pos: order batch %s from %s/%s failed, not acking: %v",
|
||||
batch.Batchid, store, terminal, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.publishAck(store, terminal, ack)
|
||||
log.Printf("pos: order batch %s from %s/%s — %d accepted, %d rejected",
|
||||
batch.Batchid, store, terminal, len(ack.Accepted), len(ack.Rejected))
|
||||
}
|
||||
|
||||
func (c *PosMqttConsumer) handleCustomers(_ mqtt.Client, msg mqtt.Message) {
|
||||
var batch models.PosCustomerBatch
|
||||
if err := json.Unmarshal(msg.Payload(), &batch); err != nil {
|
||||
log.Printf("pos: discarding unreadable customer batch on %s: %v", msg.Topic(), err)
|
||||
return
|
||||
}
|
||||
|
||||
store, terminal := topicIdentity(msg.Topic())
|
||||
if batch.Storeid == "" {
|
||||
batch.Storeid = store
|
||||
}
|
||||
if batch.Terminalid == "" {
|
||||
batch.Terminalid = terminal
|
||||
}
|
||||
|
||||
ack, err := c.svc.IngestCustomers(batch)
|
||||
if err != nil {
|
||||
log.Printf("pos: customer batch %s from %s/%s failed, not acking: %v",
|
||||
batch.Batchid, store, terminal, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.publishAck(store, terminal, ack)
|
||||
}
|
||||
|
||||
// handleHealth records one heartbeat.
|
||||
//
|
||||
// Never acknowledged. Presence is fire-and-forget: a till whose heartbeat
|
||||
// failed must carry on selling, and a blank square on a dashboard is a far
|
||||
// better outcome than a terminal that stopped because Redis was busy.
|
||||
func (c *PosMqttConsumer) handleHealth(_ mqtt.Client, msg mqtt.Message) {
|
||||
var health models.PosHealth
|
||||
if err := json.Unmarshal(msg.Payload(), &health); err != nil {
|
||||
log.Printf("pos: discarding unreadable heartbeat on %s: %v", msg.Topic(), err)
|
||||
return
|
||||
}
|
||||
|
||||
// From the topic, not the body — the same rule bills follow.
|
||||
store, terminal := topicIdentity(msg.Topic())
|
||||
if health.Locationid == "" {
|
||||
health.Locationid = store
|
||||
}
|
||||
if health.Terminalid == "" {
|
||||
health.Terminalid = terminal
|
||||
}
|
||||
|
||||
// The broker's Last Will arrives here too, as a bare {"status":"offline"}
|
||||
// with no other fields, which is exactly what should be recorded when a
|
||||
// till loses power mid-shift.
|
||||
if health.Status == "" {
|
||||
health.Status = "online"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := c.svc.RecordHealth(ctx, health); err != nil {
|
||||
log.Printf("pos: could not record heartbeat from %s/%s: %v", store, terminal, err)
|
||||
}
|
||||
}
|
||||
|
||||
// publishAck answers the till that sent the batch, and only that till.
|
||||
func (c *PosMqttConsumer) publishAck(store, terminal string, ack *models.PosAck) {
|
||||
if store == "" || terminal == "" {
|
||||
log.Printf("pos: cannot ack batch %s — the topic named no terminal", ack.Batchid)
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(ack)
|
||||
if err != nil {
|
||||
log.Printf("pos: could not encode ack for batch %s: %v", ack.Batchid, err)
|
||||
return
|
||||
}
|
||||
|
||||
topic := fmt.Sprintf("nearle/pos/%s/%s/ack", store, terminal)
|
||||
|
||||
// QoS 1: losing an ack means the till re-sends bills that are already
|
||||
// banked. Harmless, because the ingest deduplicates — but wasted traffic on
|
||||
// a shop line that may not have much to spare.
|
||||
token := c.client.Publish(topic, 1, false, payload)
|
||||
if !token.WaitTimeout(10*time.Second) || token.Error() != nil {
|
||||
log.Printf("pos: could not publish ack to %s: %v", topic, token.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// topicIdentity reads the store and terminal out of
|
||||
// `nearle/pos/<store>/<terminal>/<kind>`.
|
||||
//
|
||||
// Taken from the topic rather than the body on purpose: a till that could name
|
||||
// a store in its payload could post sales into another shop's books.
|
||||
func topicIdentity(topic string) (store, terminal string) {
|
||||
parts := strings.Split(topic, "/")
|
||||
if len(parts) < 5 {
|
||||
return "", ""
|
||||
}
|
||||
return parts[2], parts[3]
|
||||
}
|
||||
|
||||
// PublishCatalogueChanged tells every till in a store to pull now.
|
||||
//
|
||||
// Retained, so a terminal that was switched off during the change still hears
|
||||
// about it when it comes back.
|
||||
func (c *PosMqttConsumer) PublishCatalogueChanged(storeID, revision string) error {
|
||||
payload, err := json.Marshal(map[string]string{"revision": revision})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token := c.client.Publish(fmt.Sprintf("nearle/pos/%s/catalogue", storeID), 1, true, payload)
|
||||
token.Wait()
|
||||
return token.Error()
|
||||
}
|
||||
|
||||
// Close disconnects, allowing a moment for in-flight acks to leave.
|
||||
func (c *PosMqttConsumer) Close() {
|
||||
if c == nil || c.client == nil {
|
||||
return
|
||||
}
|
||||
quiesce, err := strconv.Atoi(getEnvDefault("MQTT_QUIESCE_MS", "2000"))
|
||||
if err != nil || quiesce < 0 {
|
||||
quiesce = 2000
|
||||
}
|
||||
c.client.Disconnect(uint(quiesce))
|
||||
}
|
||||
|
||||
func getEnvDefault(key, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
370
messaging/posmqtt_test.go
Normal file
370
messaging/posmqtt_test.go
Normal file
@@ -0,0 +1,370 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nearle/models"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
// These drive the real handlers through paho's own interfaces, so what is under
|
||||
// test is the code that runs in production rather than a parallel
|
||||
// reimplementation of it.
|
||||
//
|
||||
// No embedded broker: the infrastructure audit established that the broker is
|
||||
// Mosquitto 2.1.2 and that it works. What was never established is whether
|
||||
// *this* code acks the right terminal, and refuses to ack when the ingest
|
||||
// failed — which is where a bug would cost a shop its takings.
|
||||
|
||||
// fakePosService lets a test decide what the ingest did.
|
||||
type fakePosService struct {
|
||||
ack *models.PosAck
|
||||
err error
|
||||
batches []models.PosOrderBatch
|
||||
custBatch []models.PosCustomerBatch
|
||||
heartbeats []models.PosHealth
|
||||
healthErr error
|
||||
}
|
||||
|
||||
func (f *fakePosService) IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error) {
|
||||
f.batches = append(f.batches, batch)
|
||||
return f.ack, f.err
|
||||
}
|
||||
|
||||
func (f *fakePosService) IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error) {
|
||||
f.custBatch = append(f.custBatch, batch)
|
||||
return f.ack, f.err
|
||||
}
|
||||
|
||||
func (f *fakePosService) Catalogue(string, string, int, int) (*models.PosCatalogueResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePosService) RecordHealth(_ context.Context, health models.PosHealth) error {
|
||||
f.heartbeats = append(f.heartbeats, health)
|
||||
return f.healthErr
|
||||
}
|
||||
|
||||
func (f *fakePosService) TerminalHealth(context.Context, string) (map[string]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePosService) LocationHealth(context.Context, string) ([]map[string]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- paho fakes
|
||||
|
||||
type published struct {
|
||||
topic string
|
||||
qos byte
|
||||
retained bool
|
||||
payload []byte
|
||||
}
|
||||
|
||||
// fakeClient records what was published and nothing else.
|
||||
type fakeClient struct {
|
||||
mu sync.Mutex
|
||||
sent []published
|
||||
}
|
||||
|
||||
func (c *fakeClient) Publish(topic string, qos byte, retained bool, payload any) mqtt.Token {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
body, _ := payload.([]byte)
|
||||
c.sent = append(c.sent, published{topic: topic, qos: qos, retained: retained, payload: body})
|
||||
return doneToken{}
|
||||
}
|
||||
|
||||
func (c *fakeClient) publishes() []published {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]published(nil), c.sent...)
|
||||
}
|
||||
|
||||
func (c *fakeClient) IsConnected() bool { return true }
|
||||
func (c *fakeClient) IsConnectionOpen() bool { return true }
|
||||
func (c *fakeClient) Connect() mqtt.Token { return doneToken{} }
|
||||
func (c *fakeClient) Disconnect(uint) {}
|
||||
func (c *fakeClient) Subscribe(string, byte, mqtt.MessageHandler) mqtt.Token {
|
||||
return doneToken{}
|
||||
}
|
||||
func (c *fakeClient) SubscribeMultiple(map[string]byte, mqtt.MessageHandler) mqtt.Token {
|
||||
return doneToken{}
|
||||
}
|
||||
func (c *fakeClient) Unsubscribe(...string) mqtt.Token { return doneToken{} }
|
||||
func (c *fakeClient) AddRoute(string, mqtt.MessageHandler) {}
|
||||
func (c *fakeClient) OptionsReader() mqtt.ClientOptionsReader { return mqtt.ClientOptionsReader{} }
|
||||
|
||||
type doneToken struct{}
|
||||
|
||||
func (doneToken) Wait() bool { return true }
|
||||
func (doneToken) WaitTimeout(time.Duration) bool { return true }
|
||||
func (doneToken) Done() <-chan struct{} {
|
||||
ch := make(chan struct{})
|
||||
close(ch)
|
||||
return ch
|
||||
}
|
||||
func (doneToken) Error() error { return nil }
|
||||
|
||||
type fakeMessage struct {
|
||||
topic string
|
||||
payload []byte
|
||||
}
|
||||
|
||||
func (m fakeMessage) Duplicate() bool { return false }
|
||||
func (m fakeMessage) Qos() byte { return 1 }
|
||||
func (m fakeMessage) Retained() bool { return false }
|
||||
func (m fakeMessage) Topic() string { return m.topic }
|
||||
func (m fakeMessage) MessageID() uint16 { return 1 }
|
||||
func (m fakeMessage) Payload() []byte { return m.payload }
|
||||
func (m fakeMessage) Ack() {}
|
||||
|
||||
func consumerFor(svc *fakePosService) (*PosMqttConsumer, *fakeClient) {
|
||||
client := &fakeClient{}
|
||||
return &PosMqttConsumer{client: client, svc: svc}, client
|
||||
}
|
||||
|
||||
func orderBatch(batchID string, ids ...string) []byte {
|
||||
orders := make([]models.PosOrder, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
orders = append(orders, models.PosOrder{Id: id, Invoicenumber: "INV-" + id})
|
||||
}
|
||||
body, _ := json.Marshal(models.PosOrderBatch{Schema: 1, Batchid: batchID, Orders: orders})
|
||||
return body
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------- tests
|
||||
|
||||
func TestAckGoesBackToTheTerminalThatSent(t *testing.T) {
|
||||
ack := models.NewPosAck("batch-1")
|
||||
ack.Accept("order-a")
|
||||
c, client := consumerFor(&fakePosService{ack: ack})
|
||||
|
||||
c.handleOrders(nil, fakeMessage{
|
||||
topic: "nearle/pos/12/T4A9/order",
|
||||
payload: orderBatch("batch-1", "order-a"),
|
||||
})
|
||||
|
||||
sent := client.publishes()
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("published %d messages, want exactly 1", len(sent))
|
||||
}
|
||||
|
||||
// Addressed to the till that sent it. A store-wide ack would tell every
|
||||
// other counter that bills they never sent had landed.
|
||||
if sent[0].topic != "nearle/pos/12/T4A9/ack" {
|
||||
t.Errorf("ack topic = %q, want nearle/pos/12/T4A9/ack", sent[0].topic)
|
||||
}
|
||||
if sent[0].qos != 1 {
|
||||
t.Errorf("ack qos = %d, want 1", sent[0].qos)
|
||||
}
|
||||
if sent[0].retained {
|
||||
t.Error("the ack was retained; a stale ack replayed to a new session would retire bills that were never sent")
|
||||
}
|
||||
|
||||
var got models.PosAck
|
||||
if err := json.Unmarshal(sent[0].payload, &got); err != nil {
|
||||
t.Fatalf("decode ack: %v", err)
|
||||
}
|
||||
if got.Batchid != "batch-1" {
|
||||
t.Errorf("batch_id = %q, want batch-1", got.Batchid)
|
||||
}
|
||||
if len(got.Accepted) != 1 || got.Accepted[0] != "order-a" {
|
||||
t.Errorf("accepted = %v, want [order-a]", got.Accepted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedIngestIsNotAcked(t *testing.T) {
|
||||
// The single most important behaviour here. An ack the ingest did not earn
|
||||
// tells a terminal to delete a bill that was never banked.
|
||||
c, client := consumerFor(&fakePosService{err: errors.New("database is having a bad minute")})
|
||||
|
||||
c.handleOrders(nil, fakeMessage{
|
||||
topic: "nearle/pos/12/T4A9/order",
|
||||
payload: orderBatch("batch-2", "order-a"),
|
||||
})
|
||||
|
||||
if sent := client.publishes(); len(sent) != 0 {
|
||||
t.Fatalf("a batch that failed to commit was acknowledged: %s", sent[0].payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAndTerminalComeFromTheTopic(t *testing.T) {
|
||||
// The body is authoritative for nothing about identity. A till that could
|
||||
// name a store in its payload could post sales into another shop's books.
|
||||
svc := &fakePosService{ack: models.NewPosAck("batch-3")}
|
||||
c, _ := consumerFor(svc)
|
||||
|
||||
c.handleOrders(nil, fakeMessage{
|
||||
topic: "nearle/pos/44/T0001/order",
|
||||
payload: orderBatch("batch-3", "order-x"),
|
||||
})
|
||||
|
||||
if len(svc.batches) != 1 {
|
||||
t.Fatalf("the batch never reached the ingest")
|
||||
}
|
||||
if got := svc.batches[0].Storeid; got != "44" {
|
||||
t.Errorf("store_id = %q, want 44 (from the topic)", got)
|
||||
}
|
||||
if got := svc.batches[0].Terminalid; got != "T0001" {
|
||||
t.Errorf("terminal_id = %q, want T0001", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABodyCannotOverrideTheTopicIdentity(t *testing.T) {
|
||||
// A till claiming to be somewhere else must not be believed.
|
||||
svc := &fakePosService{ack: models.NewPosAck("batch-4")}
|
||||
c, client := consumerFor(svc)
|
||||
|
||||
body, _ := json.Marshal(models.PosOrderBatch{
|
||||
Schema: 1,
|
||||
Batchid: "batch-4",
|
||||
Storeid: "99", // a shop this till has no claim on
|
||||
Orders: []models.PosOrder{{Id: "order-a"}},
|
||||
})
|
||||
|
||||
c.handleOrders(nil, fakeMessage{topic: "nearle/pos/12/T4A9/order", payload: body})
|
||||
|
||||
// The ingest still resolves the store it was *told*, which is a known gap —
|
||||
// but the ack must go back to the real terminal, so a forged store id
|
||||
// cannot redirect another till's acknowledgements.
|
||||
sent := client.publishes()
|
||||
if len(sent) != 1 || sent[0].topic != "nearle/pos/12/T4A9/ack" {
|
||||
t.Fatalf("ack went to %v, want nearle/pos/12/T4A9/ack", sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMalformedBatchIsDroppedWithoutAcking(t *testing.T) {
|
||||
// Nothing to key an ack on, and nothing committed. Silence is correct: the
|
||||
// till times out and re-sends.
|
||||
svc := &fakePosService{ack: models.NewPosAck("x")}
|
||||
c, client := consumerFor(svc)
|
||||
|
||||
c.handleOrders(nil, fakeMessage{
|
||||
topic: "nearle/pos/12/T4A9/order",
|
||||
payload: []byte("not json at all"),
|
||||
})
|
||||
|
||||
if len(svc.batches) != 0 {
|
||||
t.Error("an unreadable batch reached the ingest")
|
||||
}
|
||||
if sent := client.publishes(); len(sent) != 0 {
|
||||
t.Errorf("an unreadable batch was acknowledged: %s", sent[0].payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationsAckOnTheSameTopic(t *testing.T) {
|
||||
ack := models.NewPosAck("cust-1")
|
||||
ack.Accept("customer-a")
|
||||
c, client := consumerFor(&fakePosService{ack: ack})
|
||||
|
||||
body, _ := json.Marshal(models.PosCustomerBatch{
|
||||
Schema: 1,
|
||||
Batchid: "cust-1",
|
||||
Customers: []models.PosCustomer{{Id: "customer-a", Mobile: "9840012345", Name: "Meena"}},
|
||||
})
|
||||
|
||||
c.handleCustomers(nil, fakeMessage{topic: "nearle/pos/12/T4A9/customer", payload: body})
|
||||
|
||||
sent := client.publishes()
|
||||
if len(sent) != 1 || sent[0].topic != "nearle/pos/12/T4A9/ack" {
|
||||
t.Fatalf("registration ack went to %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHeartbeatIsRecordedAndNeverAcked(t *testing.T) {
|
||||
// Presence is fire-and-forget. A till waiting on an ack for its heartbeat
|
||||
// would be a till that a busy dashboard can block.
|
||||
svc := &fakePosService{}
|
||||
c, client := consumerFor(svc)
|
||||
|
||||
body, _ := json.Marshal(models.PosHealth{Status: "online", Pendingbills: 4, Todaybills: 37})
|
||||
c.handleHealth(nil, fakeMessage{topic: "nearle/pos/12/T4A9/health", payload: body})
|
||||
|
||||
if len(svc.heartbeats) != 1 {
|
||||
t.Fatalf("the heartbeat never reached the presence store")
|
||||
}
|
||||
got := svc.heartbeats[0]
|
||||
if got.Terminalid != "T4A9" || got.Locationid != "12" {
|
||||
t.Errorf("identity = %s/%s, want 12/T4A9 (from the topic)", got.Locationid, got.Terminalid)
|
||||
}
|
||||
if got.Pendingbills != 4 {
|
||||
t.Errorf("pending_bills = %d, want 4", got.Pendingbills)
|
||||
}
|
||||
|
||||
if sent := client.publishes(); len(sent) != 0 {
|
||||
t.Error("a heartbeat was acknowledged; presence must be fire-and-forget")
|
||||
}
|
||||
}
|
||||
|
||||
func TestALastWillIsRecordedAsOffline(t *testing.T) {
|
||||
// The broker publishes this on the till's behalf when it loses power. It
|
||||
// carries nothing but a status, and that is the point — it is the only way
|
||||
// to tell "closed for the night" from "unplugged".
|
||||
svc := &fakePosService{}
|
||||
c, _ := consumerFor(svc)
|
||||
|
||||
c.handleHealth(nil, fakeMessage{
|
||||
topic: "nearle/pos/12/T4A9/health",
|
||||
payload: []byte(`{"status":"offline"}`),
|
||||
})
|
||||
|
||||
if len(svc.heartbeats) != 1 {
|
||||
t.Fatal("the will never reached the presence store")
|
||||
}
|
||||
if got := svc.heartbeats[0].Status; got != "offline" {
|
||||
t.Errorf("status = %q, want offline — a will must not be defaulted to online", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedPresenceWriteDoesNotStopTheTill(t *testing.T) {
|
||||
// Redis being unreachable must cost the board, never a sale.
|
||||
svc := &fakePosService{healthErr: errors.New("redis is down")}
|
||||
c, client := consumerFor(svc)
|
||||
|
||||
c.handleHealth(nil, fakeMessage{
|
||||
topic: "nearle/pos/12/T4A9/health",
|
||||
payload: []byte(`{"status":"online"}`),
|
||||
})
|
||||
|
||||
if sent := client.publishes(); len(sent) != 0 {
|
||||
t.Error("a failed heartbeat produced a message back to the till")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyAckSerialisesAsAListNotNull(t *testing.T) {
|
||||
// A terminal reading `null` for accepted treats the whole batch as
|
||||
// unconfirmed and sends it again for ever.
|
||||
body, err := json.Marshal(models.NewPosAck("batch-5"))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if want := `"accepted":[]`; !strings.Contains(string(body), want) {
|
||||
t.Errorf("ack serialised as %s, want it to contain %s", body, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopicIdentityRejectsShortTopics(t *testing.T) {
|
||||
// A topic that names no terminal must yield nothing rather than a
|
||||
// plausible-looking wrong answer that sends an ack to the wrong place.
|
||||
for _, topic := range []string{"nearle/pos/order", "pos/12/T4A9/order", "", "nearle"} {
|
||||
store, terminal := topicIdentity(topic)
|
||||
if store != "" || terminal != "" {
|
||||
t.Errorf("topicIdentity(%q) = %q/%q, want empty", topic, store, terminal)
|
||||
}
|
||||
}
|
||||
|
||||
store, terminal := topicIdentity("nearle/pos/12/T4A9/order")
|
||||
if store != "12" || terminal != "T4A9" {
|
||||
t.Errorf("topicIdentity = %q/%q, want 12/T4A9", store, terminal)
|
||||
}
|
||||
}
|
||||
212
models/pos.go
Normal file
212
models/pos.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package models
|
||||
|
||||
// Wire format for the Nearle POS terminal.
|
||||
//
|
||||
// These types mirror what the till actually publishes, field for field. The
|
||||
// terminal is the fixed side of this contract: it is installed on a hundred
|
||||
// machines that cannot all be updated at once, so the names here follow its
|
||||
// JSON rather than this codebase's usual Go casing.
|
||||
//
|
||||
// The authoritative description lives in the terminal repository at
|
||||
// docs/sync-contract.md.
|
||||
|
||||
// PosOrderItem is one line of a counter bill.
|
||||
//
|
||||
// Productid arrives as a string because the till stores catalogue ids as text.
|
||||
// It carries the numeric products.productid this backend issued during a
|
||||
// catalogue pull, so it parses back to an int on arrival.
|
||||
type PosOrderItem struct {
|
||||
Productid string `json:"product_id"`
|
||||
Barcode string `json:"barcode"`
|
||||
Name string `json:"name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Unitprice float64 `json:"unit_price"`
|
||||
Discount float64 `json:"discount"`
|
||||
Gstrate float64 `json:"gst_rate"`
|
||||
Tax float64 `json:"tax"`
|
||||
Linetotal float64 `json:"line_total"`
|
||||
}
|
||||
|
||||
// PosOrderCustomer is the shopper snapshot carried on the bill itself.
|
||||
//
|
||||
// Deliberately thin. The full profile travels on its own uplink; this exists so
|
||||
// a bill can be attached to somebody even when their registration has not
|
||||
// arrived yet.
|
||||
type PosOrderCustomer struct {
|
||||
Id string `json:"id"`
|
||||
Mobile string `json:"mobile"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// PosOrderPayment is one tender against a bill. A bill may be split across
|
||||
// several.
|
||||
type PosOrderPayment struct {
|
||||
Method string `json:"method"`
|
||||
Amount float64 `json:"amount"`
|
||||
Reference string `json:"reference"`
|
||||
}
|
||||
|
||||
// PosOrderPromo records a campaign that fired, as an amount rather than a rule.
|
||||
// A bill read back years later must show what was actually given, not what
|
||||
// today's rules would give.
|
||||
type PosOrderPromo struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
// PosOrder is one completed sale.
|
||||
//
|
||||
// Id is a UUID minted at the till and is the only thing that identifies this
|
||||
// bill. It is what deduplication keys on, because at-least-once delivery means
|
||||
// the same bill legitimately arrives more than once.
|
||||
type PosOrder struct {
|
||||
Id string `json:"id"`
|
||||
Invoicenumber string `json:"invoice_number"`
|
||||
Createdat string `json:"created_at"`
|
||||
Terminalid string `json:"terminal_id"`
|
||||
Cashier string `json:"cashier"`
|
||||
Customer *PosOrderCustomer `json:"customer"`
|
||||
Subtotal float64 `json:"subtotal"`
|
||||
Discount float64 `json:"discount"`
|
||||
Promos []PosOrderPromo `json:"promos"`
|
||||
Tax float64 `json:"tax"`
|
||||
Roundoff float64 `json:"round_off"`
|
||||
Total float64 `json:"total"`
|
||||
Pointsearned int `json:"points_earned"`
|
||||
Pointsredeemed int `json:"points_redeemed"`
|
||||
Payments []PosOrderPayment `json:"payments"`
|
||||
Items []PosOrderItem `json:"items"`
|
||||
|
||||
// GST per slab, as printed on the tax invoice: {"0.05": 12.30, "0.18": 4.50}.
|
||||
// Absent from terminals built before this field existed, which is why every
|
||||
// consumer of it has to tolerate an empty map.
|
||||
Taxbreakdown map[string]float64 `json:"tax_breakdown"`
|
||||
}
|
||||
|
||||
// PosOrderBatch is the envelope a terminal publishes.
|
||||
//
|
||||
// Storeid carries the numeric tenantlocations.locationid as a string. The
|
||||
// tenant is resolved from it server-side and never taken from the terminal — a
|
||||
// till must not be able to name the tenant it posts into.
|
||||
type PosOrderBatch struct {
|
||||
Schema int `json:"schema"`
|
||||
Batchid string `json:"batch_id"`
|
||||
Storeid string `json:"store_id"`
|
||||
Terminalid string `json:"terminal_id"`
|
||||
Sentat string `json:"sent_at"`
|
||||
Orders []PosOrder `json:"orders"`
|
||||
}
|
||||
|
||||
// PosCustomer is a shopper registered at a till.
|
||||
//
|
||||
// No loyalty figures. Points, lifetime spend and visit counts are derived from
|
||||
// the bill stream, which is idempotent and sees every counter; accepting a
|
||||
// terminal's local balance would make the last till to sync win.
|
||||
type PosCustomer struct {
|
||||
Id string `json:"id"`
|
||||
Mobile string `json:"mobile"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Gender string `json:"gender"`
|
||||
Dateofbirth string `json:"date_of_birth"`
|
||||
Registeredat string `json:"registered_at"`
|
||||
Registeredbyterminal string `json:"registered_by_terminal"`
|
||||
}
|
||||
|
||||
type PosCustomerBatch struct {
|
||||
Schema int `json:"schema"`
|
||||
Batchid string `json:"batch_id"`
|
||||
Storeid string `json:"store_id"`
|
||||
Terminalid string `json:"terminal_id"`
|
||||
Sentat string `json:"sent_at"`
|
||||
Customers []PosCustomer `json:"customers"`
|
||||
}
|
||||
|
||||
// PosAck is the only thing that retires a bill on the terminal.
|
||||
//
|
||||
// The rule the whole design rests on: a till marks a record synced if and only
|
||||
// if its id appears in Accepted. Silence is not acceptance — an empty ack, a
|
||||
// dropped connection or a 200 with no body all leave the record pending and it
|
||||
// is sent again.
|
||||
//
|
||||
// Naming an id in Rejected is a decision, not a fault: the terminal stops
|
||||
// retrying that record and waits for a person. Use it for "this bill is
|
||||
// malformed", never for "the database is having a bad minute" — for the latter,
|
||||
// do not ack at all and let the till back off and retry.
|
||||
type PosAck struct {
|
||||
Batchid string `json:"batch_id"`
|
||||
Accepted []string `json:"accepted"`
|
||||
Rejected map[string]string `json:"rejected,omitempty"`
|
||||
}
|
||||
|
||||
// NewPosAck returns an ack with non-nil members, so it serialises as `[]` and
|
||||
// `{}` rather than `null`. A terminal reading null for accepted would treat the
|
||||
// whole batch as unconfirmed.
|
||||
func NewPosAck(batchID string) *PosAck {
|
||||
return &PosAck{
|
||||
Batchid: batchID,
|
||||
Accepted: make([]string, 0),
|
||||
Rejected: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *PosAck) Accept(id string) {
|
||||
a.Accepted = append(a.Accepted, id)
|
||||
}
|
||||
|
||||
func (a *PosAck) Reject(id, reason string) {
|
||||
a.Rejected[id] = reason
|
||||
}
|
||||
|
||||
// PosCatalogueProduct is one product as the till stores it.
|
||||
type PosCatalogueProduct struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Barcode string `json:"barcode"`
|
||||
Sku string `json:"sku"`
|
||||
Category string `json:"category"`
|
||||
Price float64 `json:"price"`
|
||||
Mrp float64 `json:"mrp,omitempty"`
|
||||
Stock float64 `json:"stock"`
|
||||
Unit string `json:"unit"`
|
||||
Gstrate float64 `json:"gst_rate"`
|
||||
Hsncode string `json:"hsn_code,omitempty"`
|
||||
Brand string `json:"brand,omitempty"`
|
||||
Isactive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// PosCatalogueCustomer is a shopper travelling *down* to a terminal.
|
||||
//
|
||||
// The mirror of PosCustomer, and the difference is the point: the uplink
|
||||
// carries no loyalty figures because a till's local balance is only its own
|
||||
// view, while the downlink carries them because the back office has seen every
|
||||
// counter and is the only thing that can total them.
|
||||
type PosCatalogueCustomer struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Mobile string `json:"mobile"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Gender string `json:"gender,omitempty"`
|
||||
Dateofbirth string `json:"date_of_birth,omitempty"`
|
||||
Loyaltypoints int `json:"loyalty_points"`
|
||||
Lifetimespend float64 `json:"lifetime_spend"`
|
||||
Visitcount int `json:"visit_count"`
|
||||
Createdat string `json:"created_at,omitempty"`
|
||||
Lastvisitat string `json:"last_visit_at,omitempty"`
|
||||
}
|
||||
|
||||
// PosCatalogueResponse answers a terminal's catalogue pull.
|
||||
//
|
||||
// Isdelta is load-bearing. A response marked false is treated as a full
|
||||
// snapshot and the terminal withdraws every product it does not mention — so
|
||||
// answering a change set with false empties the shelf.
|
||||
type PosCatalogueResponse struct {
|
||||
Revision string `json:"revision"`
|
||||
Isdelta bool `json:"is_delta"`
|
||||
Hasmore bool `json:"has_more"`
|
||||
Products []PosCatalogueProduct `json:"products"`
|
||||
Customers []PosCatalogueCustomer `json:"customers"`
|
||||
Retiredids []string `json:"retired_product_ids"`
|
||||
}
|
||||
58
models/poshealth.go
Normal file
58
models/poshealth.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package models
|
||||
|
||||
// PosHealth is what a till reports about itself every 30 seconds.
|
||||
//
|
||||
// This is a liveness signal, not a record. It lives in Redis under a TTL and is
|
||||
// never written to Postgres: a terminal that dies simply stops refreshing and
|
||||
// disappears from the board on its own, with no reaper job and no row left
|
||||
// claiming "online" three days after the shop closed.
|
||||
//
|
||||
// The fields exist to answer questions a person actually asks when a shop
|
||||
// phones in: is the till on, is it reaching us, is it selling anything, and is
|
||||
// the hardware in the way.
|
||||
type PosHealth struct {
|
||||
// Identity. Terminalid is the short code printed on invoices — the thing a
|
||||
// support call starts with.
|
||||
Terminalid string `json:"terminal_id"`
|
||||
Locationid string `json:"location_id"`
|
||||
Storename string `json:"store_name"`
|
||||
Appversion string `json:"app_version"`
|
||||
|
||||
// "online" while the till is refreshing this. The broker's Last Will
|
||||
// overwrites it with "offline" if the terminal loses power mid-shift, which
|
||||
// is the only way to tell *closed for the night* from *unplugged*.
|
||||
Status string `json:"status"`
|
||||
|
||||
// Queue depth — the number that matters most. A shop quietly accumulating
|
||||
// unsynced takings looks completely normal from the shop floor, and this is
|
||||
// the only thing that makes it visible before someone reconciles a till and
|
||||
// finds a day missing.
|
||||
Pendingbills int `json:"pending_bills"`
|
||||
Pendingregistrations int `json:"pending_registrations"`
|
||||
Oldestpendingat string `json:"oldest_pending_at"`
|
||||
|
||||
// Today's trading. A till that is connected but has rung nothing in three
|
||||
// hours usually means a jammed printer or an absent cashier, and neither
|
||||
// shows up in a plain online/offline board.
|
||||
Todaybills int `json:"today_bills"`
|
||||
Todayamount float64 `json:"today_amount"`
|
||||
Lastbillat string `json:"last_bill_at"`
|
||||
|
||||
// Device state, for pre-emptive support.
|
||||
//
|
||||
// Pointers so that *not reported* is distinguishable from *reported as
|
||||
// zero*. Not every build collects these — battery and free storage need
|
||||
// platform packages a desktop till has no use for — and writing an
|
||||
// uncollected reading as 0 would show a board full of terminals on a flat
|
||||
// battery with an unreachable printer. A nil field is skipped entirely.
|
||||
Batterylevel *int `json:"battery_level,omitempty"`
|
||||
Batterycharging *bool `json:"battery_charging,omitempty"`
|
||||
Storagefreemb *int `json:"storage_free_mb,omitempty"`
|
||||
Printerreachable *bool `json:"printer_reachable,omitempty"`
|
||||
Drawerstatus *string `json:"drawer_status,omitempty"`
|
||||
|
||||
// Stamped by the till. The consumer also stamps its own arrival time, and
|
||||
// the two disagreeing is itself a signal — a till whose clock is wrong
|
||||
// writes bills under the wrong business date.
|
||||
Reportedat string `json:"reported_at"`
|
||||
}
|
||||
138
models/posorder.go
Normal file
138
models/posorder.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Counter sales, stored at the fidelity the till actually rang them.
|
||||
//
|
||||
// Separate from `orders` on purpose. An app order and a counter bill are
|
||||
// different documents: a bill carries a cashier, a terminal, a rounding
|
||||
// adjustment, promo campaigns, loyalty movement and a payment split across
|
||||
// several tenders, none of which `orders` has anywhere to put. Forcing one into
|
||||
// the other's shape loses whichever fields do not fit, and the loss is silent.
|
||||
//
|
||||
// The cost of the split is that existing revenue queries do not see these rows
|
||||
// until they are extended to union them in — done in orderRepository's summary
|
||||
// queries, and the thing to remember when adding a new report.
|
||||
//
|
||||
// Stock is *not* separate: a counter sale writes the same productstocks "out"
|
||||
// rows an app order does, through the same helper. Two stock ledgers would mean
|
||||
// the catalogue pull sends a till figures that ignore its own sales.
|
||||
|
||||
// PosOrders is one counter bill.
|
||||
type PosOrders struct {
|
||||
Posorderid int `json:"posorderid" gorm:"primaryKey;autoIncrement;column:posorderid"`
|
||||
|
||||
// The UUID minted at the till. Globally unique by construction and the only
|
||||
// thing that identifies this bill, so it carries a unique index: delivery is
|
||||
// at-least-once and the same bill legitimately arrives more than once.
|
||||
Terminalorderid string `json:"terminalorderid" gorm:"column:terminalorderid;uniqueIndex;not null"`
|
||||
|
||||
// Human-facing, and unique only per terminal — a till that was replaced
|
||||
// restarts its own series, so gaps are normal and duplicates across
|
||||
// terminals are expected.
|
||||
Invoicenumber string `json:"invoicenumber" gorm:"column:invoicenumber;index"`
|
||||
|
||||
Tenantid int `json:"tenantid" gorm:"column:tenantid;index"`
|
||||
Locationid int `json:"locationid" gorm:"column:locationid;index"`
|
||||
|
||||
// Which physical till, e.g. "T4A9". Free text: nothing keys on it, but a
|
||||
// support call starts with it.
|
||||
Terminalid string `json:"terminalid" gorm:"column:terminalid;index"`
|
||||
Cashiername string `json:"cashiername" gorm:"column:cashiername"`
|
||||
|
||||
// Resolved against the customers table. Zero for a walk-in.
|
||||
Customerid int `json:"customerid" gorm:"column:customerid;index"`
|
||||
Customermobile string `json:"customermobile" gorm:"column:customermobile"`
|
||||
Customername string `json:"customername" gorm:"column:customername"`
|
||||
|
||||
// When the sale was rung, not when it reached us — a till that was offline
|
||||
// for a day uploads bills whose Billedat is yesterday, and every daily
|
||||
// figure must use this rather than Receivedat.
|
||||
Billedat time.Time `json:"billedat" gorm:"column:billedat;index"`
|
||||
|
||||
// YYYY-MM-DD of Billedat, denormalised so a day's takings are one indexed
|
||||
// equality match rather than a range scan with timezone arithmetic.
|
||||
Businessdate string `json:"businessdate" gorm:"column:businessdate;index"`
|
||||
|
||||
Subtotal float64 `json:"subtotal" gorm:"column:subtotal"`
|
||||
Discount float64 `json:"discount" gorm:"column:discount"`
|
||||
Taxamount float64 `json:"taxamount" gorm:"column:taxamount"`
|
||||
|
||||
// The paise adjustment printed on the bill. Kept because total is not
|
||||
// derivable from the other columns without it.
|
||||
Roundoff float64 `json:"roundoff" gorm:"column:roundoff"`
|
||||
|
||||
// What the shopper actually paid. The figure every revenue report sums.
|
||||
Total float64 `json:"total" gorm:"column:total"`
|
||||
|
||||
Pointsearned int `json:"pointsearned" gorm:"column:pointsearned"`
|
||||
Pointsredeemed int `json:"pointsredeemed" gorm:"column:pointsredeemed"`
|
||||
|
||||
Itemcount int `json:"itemcount" gorm:"column:itemcount"`
|
||||
|
||||
// The largest tender, for the common "how did they pay" grouping.
|
||||
Paymentmode string `json:"paymentmode" gorm:"column:paymentmode;index"`
|
||||
|
||||
// The full split, verbatim. A bill can be part cash, part card, part
|
||||
// loyalty, and collapsing that to one mode would lose the reconciliation a
|
||||
// cashier settles their drawer against.
|
||||
Paymentsjson string `json:"paymentsjson" gorm:"column:paymentsjson;type:jsonb"`
|
||||
|
||||
// Campaigns that fired, stored as amounts rather than rules — a bill read
|
||||
// back years later must show what was given, not what today's rules give.
|
||||
Promosjson string `json:"promosjson" gorm:"column:promosjson;type:jsonb"`
|
||||
|
||||
// GST per slab, as printed on the tax invoice.
|
||||
Taxbreakdownjson string `json:"taxbreakdownjson" gorm:"column:taxbreakdownjson;type:jsonb"`
|
||||
|
||||
// Which upload carried this bill, and when it landed. Kept for tracing a
|
||||
// terminal's complaint back to a specific batch.
|
||||
Batchid string `json:"batchid" gorm:"column:batchid;index"`
|
||||
Receivedat time.Time `json:"receivedat" gorm:"column:receivedat"`
|
||||
|
||||
Created time.Time `json:"created" gorm:"column:created;autoCreateTime"`
|
||||
Updated time.Time `json:"updated" gorm:"column:updated;autoUpdateTime"`
|
||||
|
||||
Items []PosOrderItems `json:"items" gorm:"-"`
|
||||
}
|
||||
|
||||
func (PosOrders) TableName() string {
|
||||
return "pos_orders"
|
||||
}
|
||||
|
||||
// PosOrderItems is one line of a counter bill.
|
||||
type PosOrderItems struct {
|
||||
Posorderitemid int `json:"posorderitemid" gorm:"primaryKey;autoIncrement;column:posorderitemid"`
|
||||
Posorderid int `json:"posorderid" gorm:"column:posorderid;index"`
|
||||
|
||||
Tenantid int `json:"tenantid" gorm:"column:tenantid;index"`
|
||||
Locationid int `json:"locationid" gorm:"column:locationid;index"`
|
||||
Productid int `json:"productid" gorm:"column:productid;index"`
|
||||
|
||||
// Snapshotted rather than joined. A product renamed or withdrawn next month
|
||||
// must not change what a bill from today says it sold.
|
||||
Productname string `json:"productname" gorm:"column:productname"`
|
||||
Barcode string `json:"barcode" gorm:"column:barcode"`
|
||||
Unitname string `json:"unitname" gorm:"column:unitname"`
|
||||
|
||||
// Fractional: a counter sells 1.5 kg of onions. Note that productstocks
|
||||
// cannot represent that — see roundStockQty.
|
||||
Quantity float64 `json:"quantity" gorm:"column:quantity"`
|
||||
|
||||
Unitprice float64 `json:"unitprice" gorm:"column:unitprice"`
|
||||
Discountamount float64 `json:"discountamount" gorm:"column:discountamount"`
|
||||
|
||||
// Stored as a fraction (0.18), matching how the till holds it.
|
||||
Gstrate float64 `json:"gstrate" gorm:"column:gstrate"`
|
||||
Taxamount float64 `json:"taxamount" gorm:"column:taxamount"`
|
||||
|
||||
// What this line contributed to the bill total, after its share of every
|
||||
// discount. The lines sum to the bill's Total less Roundoff.
|
||||
Linetotal float64 `json:"linetotal" gorm:"column:linetotal"`
|
||||
|
||||
Created time.Time `json:"created" gorm:"column:created;autoCreateTime"`
|
||||
}
|
||||
|
||||
func (PosOrderItems) TableName() string {
|
||||
return "pos_order_items"
|
||||
}
|
||||
@@ -748,7 +748,17 @@ func (r *orderRepository) GetRevenueSummary(tid, lid int, fdate, tdate string) (
|
||||
if err := r.db.Raw(overallQuery, overallParams...).Scan(&overallRev).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary.OverallRevenue = overallRev
|
||||
|
||||
// Counter sales live in their own table, so every figure that reads
|
||||
// `orders` alone understates a shop that runs a till. Added here rather
|
||||
// than by rewriting the query above: the join and the dynamic parameters
|
||||
// are load-bearing for app orders and not worth disturbing.
|
||||
posTotal, posByLocation, err := r.posRevenue(tid, lid, fdate, tdate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
summary.OverallRevenue = overallRev + posTotal
|
||||
|
||||
// 4. Fetch revenue details by location
|
||||
locationQuery := `
|
||||
@@ -783,15 +793,163 @@ func (r *orderRepository) GetRevenueSummary(tid, lid int, fdate, tdate string) (
|
||||
if err := r.db.Raw(locationQuery, locParams...).Scan(&locRevenues).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
if locRevenues == nil {
|
||||
locRevenues = []models.LocationRevenueDetails{}
|
||||
}
|
||||
|
||||
// The location list comes from tenantlocations, so an outlet that trades
|
||||
// only through its counter still has a row here — it just has zero app
|
||||
// revenue against it. Adding rather than replacing keeps both visible.
|
||||
for i := range locRevenues {
|
||||
locRevenues[i].Revenue += posByLocation[locRevenues[i].Locationid]
|
||||
}
|
||||
|
||||
summary.LocationRevenue = locRevenues
|
||||
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
// posSalesTotals returns counter-sale revenue and bill count, overall and by
|
||||
// day, scoped exactly as GetSalesSummary scopes app orders.
|
||||
func (r *orderRepository) posSalesTotals(tid, lid int, fdate, tdate string) (
|
||||
struct {
|
||||
Revenue float64
|
||||
Orders int
|
||||
},
|
||||
[]models.SalesSummaryChartData,
|
||||
error,
|
||||
) {
|
||||
var totals struct {
|
||||
Revenue float64
|
||||
Orders int
|
||||
}
|
||||
|
||||
where := "tenantid = ?"
|
||||
params := []interface{}{tid}
|
||||
|
||||
if lid != 0 {
|
||||
where += " AND locationid = ?"
|
||||
params = append(params, lid)
|
||||
}
|
||||
if fdate != "" && tdate != "" {
|
||||
where += " AND businessdate BETWEEN ? AND ?"
|
||||
params = append(params, fdate, tdate)
|
||||
}
|
||||
|
||||
totalsQuery := fmt.Sprintf(
|
||||
`SELECT COALESCE(SUM(total), 0) AS revenue, COUNT(posorderid) AS orders
|
||||
FROM pos_orders WHERE %s`, where)
|
||||
if err := r.db.Raw(totalsQuery, params...).Scan(&totals).Error; err != nil {
|
||||
return totals, nil, err
|
||||
}
|
||||
|
||||
dailyQuery := fmt.Sprintf(
|
||||
`SELECT businessdate AS date, COALESCE(SUM(total), 0) AS revenue,
|
||||
COUNT(posorderid) AS orders
|
||||
FROM pos_orders WHERE %s
|
||||
GROUP BY businessdate ORDER BY businessdate ASC`, where)
|
||||
|
||||
var daily []models.SalesSummaryChartData
|
||||
if err := r.db.Raw(dailyQuery, params...).Scan(&daily).Error; err != nil {
|
||||
return totals, nil, err
|
||||
}
|
||||
|
||||
return totals, daily, nil
|
||||
}
|
||||
|
||||
// mergePosIntoChart folds counter sales into the app-order series by date.
|
||||
//
|
||||
// A day present in one and not the other has to appear rather than be dropped:
|
||||
// a shop that sells only over the counter has no app orders at all, and an
|
||||
// inner join on date would show it an empty chart.
|
||||
func mergePosIntoChart(
|
||||
app []models.SalesSummaryChartData,
|
||||
pos []models.SalesSummaryChartData,
|
||||
) []models.SalesSummaryChartData {
|
||||
if len(pos) == 0 {
|
||||
return app
|
||||
}
|
||||
|
||||
// Dates arrive in two shapes — the app series casts a timestamp, the POS
|
||||
// series stores a plain YYYY-MM-DD string — so both are trimmed to ten
|
||||
// characters before being matched, or every day would appear twice.
|
||||
dayOf := func(s string) string {
|
||||
if len(s) >= 10 {
|
||||
return s[:10]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
index := make(map[string]int, len(app))
|
||||
merged := make([]models.SalesSummaryChartData, 0, len(app)+len(pos))
|
||||
for _, row := range app {
|
||||
index[dayOf(row.Date)] = len(merged)
|
||||
merged = append(merged, row)
|
||||
}
|
||||
|
||||
for _, row := range pos {
|
||||
day := dayOf(row.Date)
|
||||
if at, ok := index[day]; ok {
|
||||
merged[at].Revenue += row.Revenue
|
||||
merged[at].Orders += row.Orders
|
||||
continue
|
||||
}
|
||||
index[day] = len(merged)
|
||||
merged = append(merged, models.SalesSummaryChartData{
|
||||
Date: day,
|
||||
Revenue: row.Revenue,
|
||||
Orders: row.Orders,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(merged, func(a, b int) bool {
|
||||
return dayOf(merged[a].Date) < dayOf(merged[b].Date)
|
||||
})
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// posRevenue totals counter sales, overall and per location.
|
||||
//
|
||||
// Scoped the same way GetRevenueSummary scopes app orders — tenant, optional
|
||||
// location, optional date range — so the two halves of a figure always cover
|
||||
// the same ground. Dates match on businessdate, which is the day the sale was
|
||||
// rung rather than the day it reached us: a till that was offline overnight
|
||||
// uploads yesterday's bills this morning, and they belong to yesterday.
|
||||
func (r *orderRepository) posRevenue(tid, lid int, fdate, tdate string) (float64, map[int]float64, error) {
|
||||
query := `SELECT locationid, COALESCE(SUM(total), 0) AS revenue
|
||||
FROM pos_orders WHERE tenantid = ?`
|
||||
params := []interface{}{tid}
|
||||
|
||||
if lid != 0 {
|
||||
query += " AND locationid = ?"
|
||||
params = append(params, lid)
|
||||
}
|
||||
if fdate != "" && tdate != "" {
|
||||
query += " AND businessdate BETWEEN ? AND ?"
|
||||
params = append(params, fdate, tdate)
|
||||
}
|
||||
query += " GROUP BY locationid"
|
||||
|
||||
var rows []struct {
|
||||
Locationid int
|
||||
Revenue float64
|
||||
}
|
||||
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
total := 0.0
|
||||
byLocation := make(map[int]float64, len(rows))
|
||||
for _, row := range rows {
|
||||
total += row.Revenue
|
||||
byLocation[row.Locationid] = row.Revenue
|
||||
}
|
||||
|
||||
return total, byLocation, nil
|
||||
}
|
||||
|
||||
func (r *orderRepository) GetDistinctLocations() ([]models.OrderInsight, error) {
|
||||
var locations []models.OrderInsight
|
||||
|
||||
@@ -809,44 +967,52 @@ func (r *orderRepository) GetDistinctLocations() ([]models.OrderInsight, error)
|
||||
|
||||
func (r *orderRepository) GetSalesSummary(tid, lid int, fdate, tdate string) (*models.SalesSummaryResponse, error) {
|
||||
var summary models.SalesSummaryResponse
|
||||
|
||||
|
||||
whereClause := "tenantid = ? AND orderstatus IN ('delivered', 'completed') AND configid = 1"
|
||||
var params []interface{}
|
||||
params = append(params, tid)
|
||||
|
||||
|
||||
if lid != 0 {
|
||||
whereClause += " AND locationid = ?"
|
||||
params = append(params, lid)
|
||||
}
|
||||
|
||||
|
||||
if fdate != "" && tdate != "" {
|
||||
whereClause += " AND orderdate::date BETWEEN ? AND ?"
|
||||
params = append(params, fdate, tdate)
|
||||
}
|
||||
|
||||
|
||||
totalsQuery := fmt.Sprintf(`
|
||||
SELECT
|
||||
COALESCE(SUM(COALESCE(ordervalue, 0) + COALESCE(orderamount, 0) + COALESCE(deliveryamt, 0)), 0) AS total_revenue,
|
||||
COUNT(orderheaderid) AS total_orders
|
||||
FROM orders
|
||||
WHERE %s`, whereClause)
|
||||
|
||||
|
||||
var result struct {
|
||||
TotalRevenue float64
|
||||
TotalOrders int
|
||||
}
|
||||
|
||||
|
||||
if err := r.db.Raw(totalsQuery, params...).Scan(&result).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
summary.TotalRevenue = result.TotalRevenue
|
||||
summary.TotalOrders = result.TotalOrders
|
||||
|
||||
|
||||
// Counter sales, folded in before the average is taken — computing it from
|
||||
// app orders alone and then adding POS revenue would report an average
|
||||
// order value no order ever had.
|
||||
posTotals, posDaily, err := r.posSalesTotals(tid, lid, fdate, tdate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
summary.TotalRevenue = result.TotalRevenue + posTotals.Revenue
|
||||
summary.TotalOrders = result.TotalOrders + posTotals.Orders
|
||||
|
||||
if summary.TotalOrders > 0 {
|
||||
summary.AverageOrderValue = summary.TotalRevenue / float64(summary.TotalOrders)
|
||||
}
|
||||
|
||||
|
||||
chartQuery := fmt.Sprintf(`
|
||||
SELECT
|
||||
CAST(orderdate AS DATE) AS date,
|
||||
@@ -856,17 +1022,21 @@ func (r *orderRepository) GetSalesSummary(tid, lid int, fdate, tdate string) (*m
|
||||
WHERE %s
|
||||
GROUP BY CAST(orderdate AS DATE)
|
||||
ORDER BY date ASC`, whereClause)
|
||||
|
||||
|
||||
var chartData []models.SalesSummaryChartData
|
||||
if err := r.db.Raw(chartQuery, params...).Scan(&chartData).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
if chartData == nil {
|
||||
chartData = []models.SalesSummaryChartData{}
|
||||
}
|
||||
|
||||
// Merged by day. A shop that only trades over the counter would otherwise
|
||||
// show a flat line at zero on every chart in the product.
|
||||
chartData = mergePosIntoChart(chartData, posDaily)
|
||||
summary.ChartData = chartData
|
||||
|
||||
|
||||
var topLocations []models.SalesSummaryTopLocation
|
||||
if lid == 0 {
|
||||
topWhere := "o.tenantid = ? AND o.orderstatus IN ('delivered', 'completed') AND o.configid = 1"
|
||||
@@ -876,7 +1046,7 @@ func (r *orderRepository) GetSalesSummary(tid, lid int, fdate, tdate string) (*m
|
||||
topWhere += " AND o.orderdate::date BETWEEN ? AND ?"
|
||||
topParams = append(topParams, fdate, tdate)
|
||||
}
|
||||
|
||||
|
||||
cleanTopLocQuery := fmt.Sprintf(`
|
||||
SELECT
|
||||
COALESCE(l.locationname, 'Unknown') AS locationname,
|
||||
@@ -887,17 +1057,17 @@ func (r *orderRepository) GetSalesSummary(tid, lid int, fdate, tdate string) (*m
|
||||
GROUP BY l.locationid, l.locationname
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 5`, topWhere)
|
||||
|
||||
|
||||
if err := r.db.Raw(cleanTopLocQuery, topParams...).Scan(&topLocations).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if topLocations == nil {
|
||||
topLocations = []models.SalesSummaryTopLocation{}
|
||||
}
|
||||
summary.TopLocations = topLocations
|
||||
|
||||
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
@@ -1220,84 +1390,38 @@ func (r *orderRepository) createOrderTx(tx *gorm.DB, data models.Orders) (models
|
||||
}
|
||||
|
||||
// 🛠️ Step 0: Lock every (tenantid, locationid, productid) row this order
|
||||
// touches before checking availability. Without this, two concurrent
|
||||
// orders for the same product can both read "stock available" before
|
||||
// either commits its deduction, oversell the item, and drive stock
|
||||
// negative. Locking productlocations — the row the stock computation is
|
||||
// already keyed against — serializes conflicting orders instead.
|
||||
// touches, then check availability under those locks.
|
||||
//
|
||||
// Locks are acquired in a fixed (productid, locationid) order so that
|
||||
// two orders sharing overlapping products always contend for them in
|
||||
// the same sequence, avoiding a lock-ordering deadlock between the two
|
||||
// transactions (as opposed to just making each individually block).
|
||||
type lockTarget struct {
|
||||
productid int
|
||||
locationid int
|
||||
}
|
||||
seen := make(map[lockTarget]bool)
|
||||
locks := make([]lockTarget, 0, len(data.Items))
|
||||
// Both now live in stockLedger.go, shared with the POS ingest — one
|
||||
// implementation of the rule that stops overselling, rather than one per
|
||||
// caller waiting to drift out of step with the others. Behaviour here is
|
||||
// unchanged, including legacyOrderQty's truncate-then-floor-at-1, which is
|
||||
// what this path has always done.
|
||||
lines := make([]stockLine, 0, len(data.Items))
|
||||
for _, item := range data.Items {
|
||||
itemLocID := item.Locationid
|
||||
if itemLocID == 0 {
|
||||
itemLocID = locID
|
||||
}
|
||||
lt := lockTarget{productid: item.Productid, locationid: itemLocID}
|
||||
if !seen[lt] {
|
||||
seen[lt] = true
|
||||
locks = append(locks, lt)
|
||||
}
|
||||
lines = append(lines, stockLine{
|
||||
Productid: item.Productid,
|
||||
Locationid: itemLocID,
|
||||
Productname: item.Productname,
|
||||
Quantity: item.Orderqty,
|
||||
})
|
||||
}
|
||||
sort.Slice(locks, func(a, b int) bool {
|
||||
if locks[a].productid != locks[b].productid {
|
||||
return locks[a].productid < locks[b].productid
|
||||
}
|
||||
return locks[a].locationid < locks[b].locationid
|
||||
})
|
||||
for _, lt := range locks {
|
||||
var locked int
|
||||
lockQuery := `SELECT productlocationid FROM productlocations WHERE tenantid = ? AND locationid = ? AND productid = ? FOR UPDATE`
|
||||
if err := tx.Raw(lockQuery, data.Tenantid, lt.locationid, lt.productid).Scan(&locked).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return models.Orders{}, fmt.Errorf("failed to lock stock for product %d: %w", lt.productid, err)
|
||||
}
|
||||
|
||||
if err := lockStockRows(tx, data.Tenantid, lines); err != nil {
|
||||
tx.Rollback()
|
||||
return models.Orders{}, err
|
||||
}
|
||||
|
||||
// 🛠️ Step 1: Pre-validate stock availability for all items before placing order
|
||||
for _, item := range data.Items {
|
||||
itemLocID := item.Locationid
|
||||
if itemLocID == 0 {
|
||||
itemLocID = locID
|
||||
}
|
||||
|
||||
requestedQty := int(item.Orderqty)
|
||||
if requestedQty <= 0 {
|
||||
requestedQty = 1
|
||||
}
|
||||
|
||||
var availableStock int
|
||||
stockQuery := `
|
||||
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 = ? AND tenantid = ? AND locationid = ?
|
||||
`
|
||||
if err := tx.Raw(stockQuery, item.Productid, data.Tenantid, itemLocID).Scan(&availableStock).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return models.Orders{}, fmt.Errorf("failed to verify stock for product %d: %w", item.Productid, err)
|
||||
}
|
||||
|
||||
// If stock tracking exists and available stock is less than requested quantity, block order placement
|
||||
if availableStock < requestedQty {
|
||||
tx.Rollback()
|
||||
pName := item.Productname
|
||||
if pName == "" {
|
||||
pName = fmt.Sprintf("ID %d", item.Productid)
|
||||
}
|
||||
return models.Orders{}, fmt.Errorf("insufficient stock for product '%s': requested %d, available %d", pName, requestedQty, availableStock)
|
||||
}
|
||||
if err := assertStockAvailable(tx, data.Tenantid, lines, func(l stockLine) int {
|
||||
return legacyOrderQty(l.Quantity)
|
||||
}); err != nil {
|
||||
tx.Rollback()
|
||||
return models.Orders{}, err
|
||||
}
|
||||
|
||||
// 🛠️ Step 2: Create Order Header
|
||||
@@ -1331,28 +1455,17 @@ func (r *orderRepository) createOrderTx(tx *gorm.DB, data models.Orders) (models
|
||||
return models.Orders{}, err
|
||||
}
|
||||
|
||||
qty := int(item.Orderqty)
|
||||
if qty <= 0 {
|
||||
qty = 1
|
||||
}
|
||||
|
||||
stock := models.Productstock{
|
||||
Tenantid: data.Tenantid,
|
||||
Stockdate: time.Now(),
|
||||
Locationid: itemLocID,
|
||||
Productid: item.Productid,
|
||||
Quantity: qty,
|
||||
Stocktype: "out",
|
||||
Status: "Active",
|
||||
}
|
||||
if err := tx.Table("productstocks").Create(&stock).Error; err != nil {
|
||||
// Writes the "out" entry and re-derives the location's availability
|
||||
// flag from the balance it produced.
|
||||
if err := recordStockOut(
|
||||
tx,
|
||||
data.Tenantid,
|
||||
stockLine{Productid: item.Productid, Locationid: itemLocID},
|
||||
legacyOrderQty(item.Orderqty),
|
||||
); err != nil {
|
||||
tx.Rollback()
|
||||
return models.Orders{}, err
|
||||
}
|
||||
|
||||
// Re-derive the location's availability flag from the ledger balance
|
||||
// this "out" entry just produced.
|
||||
syncProductLocationStatus(tx, data.Tenantid, itemLocID, item.Productid)
|
||||
}
|
||||
|
||||
// Deliberately not committed: the caller owns the transaction boundary.
|
||||
@@ -2210,7 +2323,7 @@ func (r *orderRepository) GetTimeSeries(tenantID, locationID int, granularity, f
|
||||
|
||||
var locFilter, dateFilter string
|
||||
var params []interface{}
|
||||
|
||||
|
||||
// subquery params
|
||||
params = append(params, tenantID)
|
||||
if locationID != 0 {
|
||||
@@ -2221,7 +2334,7 @@ func (r *orderRepository) GetTimeSeries(tenantID, locationID int, granularity, f
|
||||
dateFilter = " AND o2.orderdate::date BETWEEN ? AND ?"
|
||||
params = append(params, fromDate, toDate)
|
||||
}
|
||||
|
||||
|
||||
// main query params
|
||||
params = append(params, tenantID)
|
||||
if locationID != 0 {
|
||||
@@ -2266,7 +2379,7 @@ func (r *orderRepository) GetTimeSeries(tenantID, locationID int, granularity, f
|
||||
if err := r.db.Raw(query, params...).Scan(&data).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
if data == nil {
|
||||
data = []models.TimeSeriesData{}
|
||||
}
|
||||
|
||||
195
repositories/posPresence.go
Normal file
195
repositories/posPresence.go
Normal file
@@ -0,0 +1,195 @@
|
||||
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
|
||||
}
|
||||
700
repositories/posRepository.go
Normal file
700
repositories/posRepository.go
Normal file
@@ -0,0 +1,700 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nearle/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Ingestion for the Nearle POS terminal.
|
||||
//
|
||||
// Bills arrive here already rung up and paid for — the till is the system of
|
||||
// record until we say otherwise, and it holds its own copy for a week on the
|
||||
// strength of our acknowledgement. Two consequences shape everything below.
|
||||
//
|
||||
// **A duplicate is a success.** Delivery is at-least-once: a lost ack makes a
|
||||
// terminal re-send bills that are already banked. Reporting those as failures
|
||||
// would strand a day of takings on the till for ever. So a bill we already hold
|
||||
// is accepted, silently, without touching stock again.
|
||||
//
|
||||
// **Acknowledge only after the commit.** A bill named in the ack is one the
|
||||
// terminal is entitled to delete. Saying so before the transaction lands would
|
||||
// trade a real sale for a queue position.
|
||||
//
|
||||
// The commit itself is deliberately not reimplemented here. Each bill runs
|
||||
// through createOrderTx, the same path an app order and a spreadsheet import
|
||||
// take, so stock deduction, the per-product row locks that prevent overselling,
|
||||
// the ledger entries and sequence allocation stay shared rather than forked.
|
||||
|
||||
type PosRepository interface {
|
||||
IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error)
|
||||
IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error)
|
||||
Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error)
|
||||
}
|
||||
|
||||
type posRepository struct {
|
||||
db *gorm.DB
|
||||
// Held rather than embedded so the order machinery is reached explicitly.
|
||||
orders *orderRepository
|
||||
}
|
||||
|
||||
func NewPosRepository(db *gorm.DB) PosRepository {
|
||||
return &posRepository{db: db, orders: &orderRepository{db: db}}
|
||||
}
|
||||
|
||||
// resolvePosStore turns the terminal's store_id into an authorised outlet.
|
||||
//
|
||||
// The till sends a location and nothing else. The tenant is looked up from it
|
||||
// here and never accepted from the wire: a terminal that could name its own
|
||||
// tenant could post sales into somebody else's books.
|
||||
func (r *posRepository) resolvePosStore(storeID string) (*offlineLocationContext, error) {
|
||||
locationID, err := strconv.Atoi(strings.TrimSpace(storeID))
|
||||
if err != nil || locationID <= 0 {
|
||||
return nil, fmt.Errorf("store_id %q is not a location id; configure the terminal's Store ID with the numeric locationid", storeID)
|
||||
}
|
||||
|
||||
var tenantID int
|
||||
err = r.db.Raw(
|
||||
`SELECT COALESCE(MIN(tenantid), 0) FROM tenantlocations WHERE locationid = ?`,
|
||||
locationID,
|
||||
).Scan(&tenantID).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tenantID <= 0 {
|
||||
return nil, fmt.Errorf("no outlet is registered with locationid %d", locationID)
|
||||
}
|
||||
|
||||
return r.orders.resolveOfflineLocationContext(tenantID, locationID)
|
||||
}
|
||||
|
||||
// IngestOrders commits a batch of counter bills and reports what landed.
|
||||
//
|
||||
// A failure to resolve the outlet at all returns an error rather than an ack,
|
||||
// so the terminal treats the outcome as unknown and retries. A failure on one
|
||||
// bill is reported against that bill alone and the rest still commit.
|
||||
func (r *posRepository) IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error) {
|
||||
ack := models.NewPosAck(batch.Batchid)
|
||||
if len(batch.Orders) == 0 {
|
||||
return ack, nil
|
||||
}
|
||||
|
||||
ctx, err := r.resolvePosStore(batch.Storeid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
products, err := r.orders.loadOfflineProducts(ctx.Tenantid, ctx.Locationid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(products) == 0 {
|
||||
return nil, fmt.Errorf("outlet '%s' has no products stocked against it", ctx.Locationname)
|
||||
}
|
||||
|
||||
for _, order := range batch.Orders {
|
||||
if strings.TrimSpace(order.Id) == "" {
|
||||
// Nothing to key on, so it can never be deduplicated. Refusing it
|
||||
// is safer than admitting a bill that would double on every retry.
|
||||
ack.Reject("", "order is missing its id")
|
||||
continue
|
||||
}
|
||||
if reason := r.importPosOrder(ctx, products, batch.Batchid, order); reason != "" {
|
||||
ack.Reject(order.Id, reason)
|
||||
continue
|
||||
}
|
||||
ack.Accept(order.Id)
|
||||
}
|
||||
|
||||
return ack, nil
|
||||
}
|
||||
|
||||
// importPosOrder commits one bill, or leaves nothing behind.
|
||||
//
|
||||
// Returns an empty string on success — including the case where the bill was
|
||||
// already held, which is a success from the terminal's point of view.
|
||||
//
|
||||
// The bill lands in pos_orders at full fidelity, and the stock it consumed goes
|
||||
// through the same productstocks ledger an app order uses. Those two facts pull
|
||||
// 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.
|
||||
func (r *posRepository) importPosOrder(
|
||||
ctx *offlineLocationContext,
|
||||
products map[int]offlineProduct,
|
||||
batchID string,
|
||||
order models.PosOrder,
|
||||
) string {
|
||||
if len(order.Items) == 0 {
|
||||
return "bill has no items"
|
||||
}
|
||||
|
||||
saleDate, err := parsePosSaleDate(order.Createdat)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// The till has already apportioned bill-level discounts across its lines to
|
||||
// get the tax right, but it sends each line at its own pre-apportionment
|
||||
// value. Left alone, the item rows would sum to the subtotal while the
|
||||
// header carried the total, and every report that adds up lines would
|
||||
// disagree with the one that reads the header.
|
||||
//
|
||||
// So the lines are scaled onto what was actually collected. The till's own
|
||||
// figures stay authoritative for the bill as a whole; this only decides how
|
||||
// that whole is attributed across the lines inside it.
|
||||
netAmount := order.Total - order.Roundoff
|
||||
lineSum := 0.0
|
||||
taxSum := 0.0
|
||||
for _, item := range order.Items {
|
||||
lineSum += item.Linetotal
|
||||
taxSum += item.Tax
|
||||
}
|
||||
|
||||
amountFactor := 1.0
|
||||
if lineSum > 0 && netAmount > 0 {
|
||||
amountFactor = netAmount / lineSum
|
||||
}
|
||||
taxFactor := 1.0
|
||||
if taxSum > 0 && order.Tax > 0 {
|
||||
taxFactor = order.Tax / taxSum
|
||||
}
|
||||
|
||||
items := make([]models.PosOrderItems, 0, len(order.Items))
|
||||
lines := make([]stockLine, 0, len(order.Items))
|
||||
var taxTotal float64
|
||||
|
||||
for _, raw := range order.Items {
|
||||
productID, err := strconv.Atoi(strings.TrimSpace(raw.Productid))
|
||||
if err != nil || productID <= 0 {
|
||||
return fmt.Sprintf("line '%s' has product_id %q, which is not a catalogue id", raw.Name, raw.Productid)
|
||||
}
|
||||
|
||||
// Membership of this map is the ownership check. A product absent from
|
||||
// it is either another tenant's or not stocked here, and either way the
|
||||
// bill is refused rather than posted against a catalogue it has no
|
||||
// claim on.
|
||||
product, ok := products[productID]
|
||||
if !ok {
|
||||
return fmt.Sprintf("product %d is not stocked at %s", productID, ctx.Locationname)
|
||||
}
|
||||
if raw.Quantity <= 0 {
|
||||
return fmt.Sprintf("product '%s' has a quantity of %g; it must be greater than zero", product.Productname, raw.Quantity)
|
||||
}
|
||||
|
||||
landing := raw.Linetotal * amountFactor
|
||||
taxAmount := raw.Tax * taxFactor
|
||||
gross := raw.Unitprice * raw.Quantity
|
||||
discount := gross - landing
|
||||
if discount < 0 {
|
||||
discount = 0
|
||||
}
|
||||
|
||||
taxTotal += taxAmount
|
||||
|
||||
items = append(items, models.PosOrderItems{
|
||||
Tenantid: ctx.Tenantid,
|
||||
Locationid: ctx.Locationid,
|
||||
Productid: productID,
|
||||
Productname: product.Productname,
|
||||
Barcode: raw.Barcode,
|
||||
Unitname: product.Productunit,
|
||||
Quantity: raw.Quantity,
|
||||
Unitprice: raw.Unitprice,
|
||||
Discountamount: discount,
|
||||
Gstrate: raw.Gstrate,
|
||||
Taxamount: taxAmount,
|
||||
Linetotal: landing,
|
||||
})
|
||||
|
||||
lines = append(lines, stockLine{
|
||||
Productid: productID,
|
||||
Locationid: ctx.Locationid,
|
||||
Productname: product.Productname,
|
||||
Quantity: raw.Quantity,
|
||||
})
|
||||
}
|
||||
|
||||
paymentMode := "cash"
|
||||
if len(order.Payments) > 0 {
|
||||
// The largest tender names the bill. A split paid mostly by card with
|
||||
// ten rupees of change in cash is a card sale in every report anyone
|
||||
// actually reads — the full split is kept in Paymentsjson regardless.
|
||||
largest := order.Payments[0]
|
||||
for _, p := range order.Payments[1:] {
|
||||
if p.Amount > largest.Amount {
|
||||
largest = p
|
||||
}
|
||||
}
|
||||
if m := strings.ToLower(strings.TrimSpace(largest.Method)); m != "" {
|
||||
paymentMode = m
|
||||
}
|
||||
}
|
||||
|
||||
tx := r.db.Begin()
|
||||
if tx.Error != nil {
|
||||
return fmt.Sprintf("could not start a transaction: %v", tx.Error)
|
||||
}
|
||||
|
||||
// Held for the life of the transaction, so a redelivery arriving at the
|
||||
// same moment waits here and then sees the committed row rather than racing
|
||||
// past the check below and banking the sale twice. The unique index on
|
||||
// terminalorderid would catch it either way; this turns a constraint
|
||||
// violation into an orderly "already held".
|
||||
lockKey := "possale:" + strings.ToUpper(strings.TrimSpace(order.Id))
|
||||
if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext(?))`, lockKey).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Sprintf("could not lock bill %s: %v", order.Invoicenumber, err)
|
||||
}
|
||||
|
||||
var already int
|
||||
err = tx.Raw(
|
||||
`SELECT COALESCE(COUNT(*), 0) FROM pos_orders WHERE terminalorderid = ?`,
|
||||
strings.TrimSpace(order.Id),
|
||||
).Scan(&already).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Sprintf("could not check whether bill %s was already held: %v", order.Invoicenumber, err)
|
||||
}
|
||||
if already > 0 {
|
||||
// Already banked. Accepted, not rejected — this is the ordinary result
|
||||
// of a lost ack, and calling it a failure would leave the till holding
|
||||
// a bill we have had all along. Stock is deliberately untouched.
|
||||
tx.Rollback()
|
||||
return ""
|
||||
}
|
||||
|
||||
// Locks first, then availability, then the writes — the same order an app
|
||||
// order takes, and the reason two tills selling the last unit cannot both
|
||||
// succeed.
|
||||
if err := lockStockRows(tx, ctx.Tenantid, lines); err != nil {
|
||||
tx.Rollback()
|
||||
return err.Error()
|
||||
}
|
||||
if err := assertStockAvailable(tx, ctx.Tenantid, lines, func(l stockLine) int {
|
||||
return roundStockQty(l.Quantity)
|
||||
}); err != nil {
|
||||
tx.Rollback()
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
customerID, err := r.orders.resolveOfflineCustomer(tx, ctx, posCustomerName(order), posCustomerMobile(order))
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Sprintf("could not resolve the customer: %v", err)
|
||||
}
|
||||
|
||||
bill := models.PosOrders{
|
||||
Terminalorderid: strings.TrimSpace(order.Id),
|
||||
Invoicenumber: order.Invoicenumber,
|
||||
Tenantid: ctx.Tenantid,
|
||||
Locationid: ctx.Locationid,
|
||||
Terminalid: order.Terminalid,
|
||||
Cashiername: order.Cashier,
|
||||
Customerid: customerID,
|
||||
Customermobile: posCustomerMobile(order),
|
||||
Customername: posCustomerName(order),
|
||||
Billedat: saleDate,
|
||||
// The day the sale was rung, not the day it arrived. A till that was
|
||||
// offline overnight uploads yesterday's bills this morning, and every
|
||||
// daily figure has to follow the sale rather than the upload.
|
||||
Businessdate: saleDate.Format("2006-01-02"),
|
||||
Subtotal: order.Subtotal,
|
||||
Discount: order.Discount,
|
||||
Taxamount: taxTotal,
|
||||
Roundoff: order.Roundoff,
|
||||
Total: order.Total,
|
||||
Pointsearned: order.Pointsearned,
|
||||
Pointsredeemed: order.Pointsredeemed,
|
||||
Itemcount: len(items),
|
||||
Paymentmode: paymentMode,
|
||||
Paymentsjson: posJSON(order.Payments),
|
||||
Promosjson: posJSON(order.Promos),
|
||||
// Every jsonb column must carry valid JSON. Left at Go's zero value an
|
||||
// empty string reaches Postgres and the whole insert fails with
|
||||
// "invalid input syntax for type json" — taking the bill down with it.
|
||||
Taxbreakdownjson: posJSON(order.Taxbreakdown),
|
||||
Batchid: batchID,
|
||||
Receivedat: time.Now(),
|
||||
}
|
||||
|
||||
if err := tx.Create(&bill).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Sprintf("could not write bill %s: %v", order.Invoicenumber, err)
|
||||
}
|
||||
|
||||
for i := range items {
|
||||
items[i].Posorderid = bill.Posorderid
|
||||
if err := tx.Create(&items[i]).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Sprintf("could not write a line of bill %s: %v", order.Invoicenumber, err)
|
||||
}
|
||||
|
||||
// The same ledger an app order writes to. A second stock ledger for
|
||||
// counter sales would mean the catalogue pull sends a till figures that
|
||||
// ignore the till's own trading.
|
||||
if err := recordStockOut(tx, ctx.Tenantid, lines[i], roundStockQty(lines[i].Quantity)); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Sprintf("could not deduct stock for bill %s: %v", order.Invoicenumber, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return fmt.Sprintf("could not commit bill %s: %v", order.Invoicenumber, err)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// posJSON encodes a payload column.
|
||||
//
|
||||
// Falls back to a JSON null rather than failing the bill: these columns exist
|
||||
// to be read back later, and losing one is not a reason to refuse a sale the
|
||||
// shopper has already paid for.
|
||||
func posJSON(v any) string {
|
||||
body, err := json.Marshal(v)
|
||||
if err != nil || len(body) == 0 {
|
||||
return "null"
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
func posCustomerName(order models.PosOrder) string {
|
||||
if order.Customer == nil {
|
||||
return ""
|
||||
}
|
||||
return order.Customer.Name
|
||||
}
|
||||
|
||||
func posCustomerMobile(order models.PosOrder) string {
|
||||
if order.Customer == nil {
|
||||
return ""
|
||||
}
|
||||
return order.Customer.Mobile
|
||||
}
|
||||
|
||||
// parsePosSaleDate reads the till's timestamp.
|
||||
//
|
||||
// The terminal sends ISO-8601. A blank one falls back to now; an unparseable
|
||||
// one is refused, because importing a sale under the wrong date corrupts every
|
||||
// daily revenue figure that reads it.
|
||||
func parsePosSaleDate(raw string) (time.Time, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Now(), nil
|
||||
}
|
||||
for _, layout := range []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05.999999",
|
||||
"2006-01-02 15:04:05",
|
||||
} {
|
||||
if t, err := time.Parse(layout, raw); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("unrecognised created_at %q", raw)
|
||||
}
|
||||
|
||||
// IngestCustomers records shoppers registered at a till.
|
||||
//
|
||||
// Insert-if-absent, never an update. A registration is replayed freely, and a
|
||||
// profile corrected at head office must not be reverted by a terminal replaying
|
||||
// what it captured months ago.
|
||||
func (r *posRepository) IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error) {
|
||||
ack := models.NewPosAck(batch.Batchid)
|
||||
if len(batch.Customers) == 0 {
|
||||
return ack, nil
|
||||
}
|
||||
|
||||
ctx, err := r.resolvePosStore(batch.Storeid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, customer := range batch.Customers {
|
||||
mobile := strings.TrimSpace(customer.Mobile)
|
||||
if strings.TrimSpace(customer.Id) == "" || mobile == "" {
|
||||
ack.Reject(customer.Id, "registration is missing its id or mobile number")
|
||||
continue
|
||||
}
|
||||
|
||||
if err := r.upsertPosCustomer(ctx, customer, mobile); err != nil {
|
||||
ack.Reject(customer.Id, err.Error())
|
||||
continue
|
||||
}
|
||||
ack.Accept(customer.Id)
|
||||
}
|
||||
|
||||
return ack, nil
|
||||
}
|
||||
|
||||
// upsertPosCustomer attaches the shopper to this outlet's app location.
|
||||
//
|
||||
// Matched on contactno, which is what the rest of the system already keys a
|
||||
// shopper on, so a shopper registered at a till and one who installed the app
|
||||
// end up as one row rather than two.
|
||||
func (r *posRepository) upsertPosCustomer(
|
||||
ctx *offlineLocationContext,
|
||||
customer models.PosCustomer,
|
||||
mobile string,
|
||||
) error {
|
||||
name := strings.TrimSpace(customer.Name)
|
||||
if name == "" {
|
||||
name = "Counter Customer"
|
||||
}
|
||||
|
||||
var existing int
|
||||
err := r.db.Raw(
|
||||
`SELECT COALESCE(MIN(customerid), 0) FROM customers WHERE contactno = ? AND applocationid = ?`,
|
||||
mobile, ctx.Applocationid,
|
||||
).Scan(&existing).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing > 0 {
|
||||
// Already known. Accepted without a write — the terminal's copy is not
|
||||
// newer than ours in any way we can establish.
|
||||
return nil
|
||||
}
|
||||
|
||||
// status 0 mirrors every other customer row in production, including ones
|
||||
// actively placing orders. A different value here would make a shopper
|
||||
// registered at the counter behave unlike all the others.
|
||||
var created int
|
||||
err = r.db.Raw(`
|
||||
INSERT INTO customers (configid, firstname, lastname, contactno, email, gender, dob, applocationid, locationid, status, created, updated)
|
||||
VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, 0, NOW(), NOW())
|
||||
RETURNING customerid`,
|
||||
ctx.Configid, name, mobile,
|
||||
strings.TrimSpace(customer.Email),
|
||||
strings.TrimSpace(customer.Gender),
|
||||
strings.TrimSpace(customer.Dateofbirth),
|
||||
ctx.Applocationid, ctx.Locationid,
|
||||
).Scan(&created).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created <= 0 {
|
||||
return errors.New("failed to create the customer row")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Catalogue answers a terminal's morning pull.
|
||||
//
|
||||
// Always a full snapshot today, and it says so. The terminal withdraws every
|
||||
// product a snapshot omits, so answering a change set with is_delta false would
|
||||
// empty the shelf — declaring false here is only safe because this really does
|
||||
// return everything stocked at the outlet.
|
||||
func (r *posRepository) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) {
|
||||
ctx, err := r.resolvePosStore(storeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if pageSize <= 0 || pageSize > 1000 {
|
||||
pageSize = 500
|
||||
}
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
|
||||
type row struct {
|
||||
Productid int
|
||||
Productname string
|
||||
Productsku string
|
||||
Categoryname string
|
||||
Productunit string
|
||||
Productbrand string
|
||||
Price float64
|
||||
Retailprice float64
|
||||
Taxpercent float64
|
||||
Stock float64
|
||||
Status string
|
||||
}
|
||||
|
||||
rows := make([]row, 0)
|
||||
err = r.db.Raw(`
|
||||
SELECT a.productid,
|
||||
COALESCE(a.productname, '') AS productname,
|
||||
COALESCE(a.productsku, '') AS productsku,
|
||||
COALESCE(c.categoryname, '') AS categoryname,
|
||||
COALESCE(a.productunit, '') AS productunit,
|
||||
COALESCE(a.productbrand, '') AS productbrand,
|
||||
CASE WHEN COALESCE(b.price, 0) > 0 THEN b.price ELSE COALESCE(a.retailprice, 0) END AS price,
|
||||
COALESCE(a.retailprice, 0) AS retailprice,
|
||||
COALESCE(a.taxpercent, 0) AS taxpercent,
|
||||
COALESCE((
|
||||
SELECT SUM(CASE WHEN LOWER(s.stocktype) = 'in' THEN s.quantity ELSE 0 END) -
|
||||
SUM(CASE WHEN LOWER(s.stocktype) = 'out' THEN s.quantity ELSE 0 END)
|
||||
FROM productstocks s
|
||||
WHERE s.productid = a.productid AND s.tenantid = a.tenantid AND s.locationid = b.locationid
|
||||
), 0) AS stock,
|
||||
COALESCE(b.status, '') AS status
|
||||
FROM products a
|
||||
INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid
|
||||
LEFT JOIN productcategories c ON a.categoryid = c.categoryid
|
||||
WHERE a.tenantid = ? AND b.locationid = ?
|
||||
ORDER BY a.productid
|
||||
LIMIT ? OFFSET ?`,
|
||||
ctx.Tenantid, ctx.Locationid, pageSize+1, page*pageSize,
|
||||
).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// One row beyond the page was requested purely to answer has_more without a
|
||||
// second count query.
|
||||
hasMore := len(rows) > pageSize
|
||||
if hasMore {
|
||||
rows = rows[:pageSize]
|
||||
}
|
||||
|
||||
products := make([]models.PosCatalogueProduct, 0, len(rows))
|
||||
for _, p := range rows {
|
||||
// A productid of zero is bad data, not a product — live data has at
|
||||
// least one, almost certainly an insert that never got a sequence
|
||||
// value. Sending it would put a row on the till that can never be
|
||||
// billed, because the ingest refuses any line whose id is not positive.
|
||||
if p.Productid <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
mrp := p.Retailprice
|
||||
if mrp <= p.Price {
|
||||
mrp = 0
|
||||
}
|
||||
|
||||
// Indian GST is 0/5/12/18/28, but the column holds 3, 4, 6, 7, 9, 10,
|
||||
// 15 and even -1 across live data. A negative rate would put negative
|
||||
// tax on a bill and a negative figure in a slab on a filed return, so
|
||||
// it is floored here rather than trusted.
|
||||
gstRate := p.Taxpercent / 100
|
||||
if gstRate < 0 {
|
||||
gstRate = 0
|
||||
}
|
||||
|
||||
// Withdrawn from sale when there is no selling price. Neither
|
||||
// productlocations.price nor retailprice is set on much of the estate —
|
||||
// only productcost is — and a till that can ring an item up at ₹0 is
|
||||
// worse than one that cannot ring it up at all. Pricing the product
|
||||
// makes it sellable; nothing here needs changing.
|
||||
sellable := p.Price > 0 &&
|
||||
!strings.EqualFold(strings.TrimSpace(p.Status), "outofstock")
|
||||
|
||||
products = append(products, models.PosCatalogueProduct{
|
||||
Id: strconv.Itoa(p.Productid),
|
||||
Name: p.Productname,
|
||||
Barcode: posBarcode(p.Productid, p.Productsku),
|
||||
Sku: p.Productsku,
|
||||
Category: posCategory(p.Categoryname),
|
||||
Price: p.Price,
|
||||
Mrp: mrp,
|
||||
Stock: p.Stock,
|
||||
Unit: posUnit(p.Productunit),
|
||||
Gstrate: gstRate,
|
||||
Brand: p.Productbrand,
|
||||
Isactive: sellable,
|
||||
})
|
||||
}
|
||||
|
||||
return &models.PosCatalogueResponse{
|
||||
// A revision the terminal stores and sends back on its next pull. Tied
|
||||
// to the outlet and the moment, so a shop that has pulled today can be
|
||||
// told it is already current.
|
||||
Revision: fmt.Sprintf("loc%d-%s", ctx.Locationid, time.Now().UTC().Format("20060102T150405")),
|
||||
Isdelta: false,
|
||||
Hasmore: hasMore,
|
||||
Products: products,
|
||||
Customers: make([]models.PosCatalogueCustomer, 0),
|
||||
Retiredids: make([]string, 0),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// posBarcode decides what the till scans this product by.
|
||||
//
|
||||
// The terminal holds a **unique** index on barcode, so whatever this returns has
|
||||
// to be distinct across the whole catalogue or the import fails outright.
|
||||
//
|
||||
// `products.productsku` cannot be trusted for that. Measured against live data:
|
||||
// 6,245 products carry only 93 distinct SKUs, and the single value "1" is used
|
||||
// by 5,794 of them. Mapping SKU straight to barcode would collapse most of the
|
||||
// catalogue onto one row.
|
||||
//
|
||||
// So a SKU is used only when it looks like a real scannable code — 8 to 14
|
||||
// digits, the shape of an EAN-8, UPC-A or EAN-13 — and otherwise the product id
|
||||
// stands in. The id is unique by construction, which keeps the import working
|
||||
// today; the day real barcodes are populated, scanning starts working on its own
|
||||
// with no change here.
|
||||
//
|
||||
// Until then, scanning a physical barcode at the till will not find anything.
|
||||
// That is a data problem, not a code one.
|
||||
func posBarcode(productID int, sku string) string {
|
||||
sku = strings.TrimSpace(sku)
|
||||
|
||||
if len(sku) >= 8 && len(sku) <= 14 {
|
||||
digitsOnly := true
|
||||
for _, r := range sku {
|
||||
if r < '0' || r > '9' {
|
||||
digitsOnly = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if digitsOnly {
|
||||
return sku
|
||||
}
|
||||
}
|
||||
|
||||
return strconv.Itoa(productID)
|
||||
}
|
||||
|
||||
// posCategory maps a category name onto one of the terminal's fixed buckets.
|
||||
//
|
||||
// The till ships a closed enum, so anything unrecognised has to land somewhere;
|
||||
// grocery is the catch-all it already uses for uncategorised stock.
|
||||
func posCategory(name string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||
case "dairy":
|
||||
return "dairy"
|
||||
case "fruits", "fruit":
|
||||
return "fruits"
|
||||
case "vegetables", "vegetable":
|
||||
return "vegetables"
|
||||
case "beverages", "beverage", "drinks":
|
||||
return "beverages"
|
||||
case "snacks", "snack":
|
||||
return "snacks"
|
||||
case "personal care", "personalcare":
|
||||
return "personalCare"
|
||||
case "household", "home care", "homecare":
|
||||
return "household"
|
||||
default:
|
||||
return "grocery"
|
||||
}
|
||||
}
|
||||
|
||||
// posUnit maps a unit of measure onto the terminal's enum, defaulting to pieces.
|
||||
func posUnit(unit string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(unit)) {
|
||||
case "kg", "kilogram", "kilo":
|
||||
return "kilogram"
|
||||
case "g", "gram", "grams":
|
||||
return "gram"
|
||||
case "l", "litre", "liter":
|
||||
return "litre"
|
||||
case "ml", "millilitre", "milliliter":
|
||||
return "millilitre"
|
||||
case "pack", "packet":
|
||||
return "pack"
|
||||
default:
|
||||
return "piece"
|
||||
}
|
||||
}
|
||||
97
repositories/posRepository_test.go
Normal file
97
repositories/posRepository_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package repositories
|
||||
|
||||
import "testing"
|
||||
|
||||
// The terminal holds a unique index on barcode, so this rule decides whether a
|
||||
// catalogue import succeeds at all. Measured against live data when it was
|
||||
// written: 6,245 products, 93 distinct SKUs, and "1" used by 5,794 of them.
|
||||
func TestPosBarcodeFallsBackToProductIdWhenTheSkuIsNotScannable(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
productID int
|
||||
sku string
|
||||
want string
|
||||
}{
|
||||
{"the SKU almost every product shares", 844, "1", "844"},
|
||||
{"blank SKU", 845, "", "845"},
|
||||
{"whitespace only", 846, " ", "846"},
|
||||
{"too short to be a barcode", 847, "1234567", "847"},
|
||||
{"too long to be a barcode", 848, "123456789012345", "848"},
|
||||
{"not digits", 849, "SKU-ABC-123", "849"},
|
||||
{"digits with a space", 850, "1234 5678", "850"},
|
||||
|
||||
// Real scannable codes are used as-is, so the day the catalogue carries
|
||||
// them scanning starts working with no code change.
|
||||
{"EAN-8", 851, "12345678", "12345678"},
|
||||
{"UPC-A", 852, "012345678905", "012345678905"},
|
||||
{"EAN-13", 853, "8901030865278", "8901030865278"},
|
||||
{"padded EAN-13", 854, " 8901030865278 ", "8901030865278"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := posBarcode(c.productID, c.sku); got != c.want {
|
||||
t.Errorf("posBarcode(%d, %q) = %q, want %q", c.productID, c.sku, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPosBarcodesAreUniqueAcrossACatalogueOfSharedSkus(t *testing.T) {
|
||||
// The failure this exists to prevent: a whole catalogue collapsing onto one
|
||||
// barcode and the import being rejected by the terminal's unique index.
|
||||
seen := make(map[string]int)
|
||||
for id := 844; id < 844+500; id++ {
|
||||
barcode := posBarcode(id, "1")
|
||||
if first, clash := seen[barcode]; clash {
|
||||
t.Fatalf("products %d and %d both produced barcode %q", first, id, barcode)
|
||||
}
|
||||
seen[barcode] = id
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundStockQtyNeverUnderDeducts(t *testing.T) {
|
||||
// productstocks.quantity is an integer column and a counter sells 1.5 kg of
|
||||
// onions. Rounding up keeps recorded stock at or below what is on the shelf;
|
||||
// truncating would let the shop oversell a little more with every sale.
|
||||
cases := []struct {
|
||||
quantity float64
|
||||
want int
|
||||
}{
|
||||
{1, 1},
|
||||
{1.5, 2},
|
||||
{0.25, 1},
|
||||
{2.0, 2},
|
||||
{2.01, 3},
|
||||
{0, 1},
|
||||
{-1, 1},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
if got := roundStockQty(c.quantity); got != c.want {
|
||||
t.Errorf("roundStockQty(%g) = %d, want %d", c.quantity, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyOrderQtyIsUnchanged(t *testing.T) {
|
||||
// App orders have always truncated, and that behaviour is deliberately
|
||||
// preserved rather than corrected — changing it would silently alter stock
|
||||
// deduction for every order already flowing through createOrderTx.
|
||||
cases := []struct {
|
||||
quantity float64
|
||||
want int
|
||||
}{
|
||||
{1, 1},
|
||||
{1.5, 1},
|
||||
{0.5, 1},
|
||||
{3.9, 3},
|
||||
{0, 1},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
if got := legacyOrderQty(c.quantity); got != c.want {
|
||||
t.Errorf("legacyOrderQty(%g) = %d, want %d", c.quantity, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
182
repositories/stockLedger.go
Normal file
182
repositories/stockLedger.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"nearle/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Shared stock machinery.
|
||||
//
|
||||
// Extracted from createOrderTx so that an order placed in the app and a bill
|
||||
// rung up at a counter deduct stock through exactly the same code. Two
|
||||
// implementations of the rule that stops overselling would drift, and the first
|
||||
// anyone would know about it is a shelf that is empty in the database and full
|
||||
// in the shop, or the reverse.
|
||||
//
|
||||
// None of these commit or roll back — the caller owns the transaction boundary,
|
||||
// because what should happen to the rest of the work on failure is the caller's
|
||||
// business, not the ledger's.
|
||||
|
||||
// stockLine is the minimum the ledger needs to know about one sold line.
|
||||
//
|
||||
// Deliberately not models.OrderDetail: the POS ingest writes its own tables and
|
||||
// has no OrderDetail to hand, and coupling the ledger to one caller's row type
|
||||
// is what forced the duplication this file removes.
|
||||
type stockLine struct {
|
||||
Productid int
|
||||
Locationid int
|
||||
Productname string
|
||||
|
||||
// Units sold. Fractional because a counter sells 1.5 kg of onions; the
|
||||
// ledger itself is integer-only, and roundStockQty explains the gap.
|
||||
Quantity float64
|
||||
}
|
||||
|
||||
// roundStockQty turns a sold quantity into a ledger quantity.
|
||||
//
|
||||
// productstocks.quantity is an integer column, so fractional sales cannot be
|
||||
// represented exactly. Rounding *up* is the conservative direction: 1.5 kg
|
||||
// deducts 2, so the recorded stock is never higher than what is physically on
|
||||
// the shelf. Truncating instead would under-deduct on every fractional sale and
|
||||
// let the shop oversell a little more each time.
|
||||
//
|
||||
// This is a workaround, not a fix. A shop that sells much by weight needs the
|
||||
// column to be numeric.
|
||||
func roundStockQty(quantity float64) int {
|
||||
if quantity <= 0 {
|
||||
return 1
|
||||
}
|
||||
return int(math.Ceil(quantity - 1e-9))
|
||||
}
|
||||
|
||||
// lockStockRows takes a row lock on every (tenant, location, product) the sale
|
||||
// touches, before anything reads availability.
|
||||
//
|
||||
// Without it two concurrent sales of the same product can both read "in stock"
|
||||
// before either commits its deduction, oversell the item and drive the balance
|
||||
// negative. Locking productlocations — the row the stock computation is already
|
||||
// keyed against — serialises conflicting sales instead.
|
||||
//
|
||||
// Locks are taken in a fixed (productid, locationid) order so two sales sharing
|
||||
// products always contend in the same sequence. Without that ordering they
|
||||
// deadlock against each other rather than merely blocking.
|
||||
func lockStockRows(tx *gorm.DB, tenantID int, lines []stockLine) error {
|
||||
type lockTarget struct {
|
||||
productid int
|
||||
locationid int
|
||||
}
|
||||
|
||||
seen := make(map[lockTarget]bool, len(lines))
|
||||
locks := make([]lockTarget, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
lt := lockTarget{productid: line.Productid, locationid: line.Locationid}
|
||||
if !seen[lt] {
|
||||
seen[lt] = true
|
||||
locks = append(locks, lt)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(locks, func(a, b int) bool {
|
||||
if locks[a].productid != locks[b].productid {
|
||||
return locks[a].productid < locks[b].productid
|
||||
}
|
||||
return locks[a].locationid < locks[b].locationid
|
||||
})
|
||||
|
||||
for _, lt := range locks {
|
||||
var locked int
|
||||
const q = `SELECT productlocationid FROM productlocations
|
||||
WHERE tenantid = ? AND locationid = ? AND productid = ? FOR UPDATE`
|
||||
if err := tx.Raw(q, tenantID, lt.locationid, lt.productid).Scan(&locked).Error; err != nil {
|
||||
return fmt.Errorf("failed to lock stock for product %d: %w", lt.productid, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// availableStock is the ledger balance for one product at one location.
|
||||
func availableStock(tx *gorm.DB, tenantID, locationID, productID int) (int, error) {
|
||||
var available int
|
||||
const q = `
|
||||
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 = ? AND tenantid = ? AND locationid = ?`
|
||||
|
||||
if err := tx.Raw(q, productID, tenantID, locationID).Scan(&available).Error; err != nil {
|
||||
return 0, fmt.Errorf("failed to verify stock for product %d: %w", productID, err)
|
||||
}
|
||||
return available, nil
|
||||
}
|
||||
|
||||
// assertStockAvailable refuses the whole sale if any line cannot be met.
|
||||
//
|
||||
// Checked for every line before any is written, so a sale never lands
|
||||
// half-deducted. Call it only with the locks from lockStockRows already held —
|
||||
// otherwise the balance it reads can change before the deduction is written.
|
||||
func assertStockAvailable(tx *gorm.DB, tenantID int, lines []stockLine, qtyOf func(stockLine) int) error {
|
||||
for _, line := range lines {
|
||||
available, err := availableStock(tx, tenantID, line.Locationid, line.Productid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requested := qtyOf(line)
|
||||
if available < requested {
|
||||
name := line.Productname
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("ID %d", line.Productid)
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"insufficient stock for product '%s': requested %d, available %d",
|
||||
name, requested, available,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordStockOut writes the ledger entry for one sold line and re-derives the
|
||||
// location's availability flag from the balance it just produced.
|
||||
func recordStockOut(tx *gorm.DB, tenantID int, line stockLine, quantity int) error {
|
||||
stock := models.Productstock{
|
||||
Tenantid: tenantID,
|
||||
Stockdate: time.Now(),
|
||||
Locationid: line.Locationid,
|
||||
Productid: line.Productid,
|
||||
Quantity: quantity,
|
||||
Stocktype: "out",
|
||||
Status: "Active",
|
||||
}
|
||||
if err := tx.Table("productstocks").Create(&stock).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
syncProductLocationStatus(tx, tenantID, line.Locationid, line.Productid)
|
||||
return nil
|
||||
}
|
||||
|
||||
// legacyOrderQty is how createOrderTx has always turned an order quantity into
|
||||
// a ledger quantity: truncate, then floor at 1.
|
||||
//
|
||||
// Preserved exactly rather than corrected, because changing it would silently
|
||||
// alter stock deduction for every app order in production. It under-deducts a
|
||||
// fractional line — 1.5 becomes 1 — which is why the POS path uses
|
||||
// roundStockQty instead. Worth reconciling once someone owns the decision.
|
||||
func legacyOrderQty(quantity float64) int {
|
||||
q := int(quantity)
|
||||
if q <= 0 {
|
||||
q = 1
|
||||
}
|
||||
return q
|
||||
}
|
||||
31
routes/posroutes.go
Normal file
31
routes/posroutes.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"nearle/facade"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// Routes for the Nearle POS terminal.
|
||||
//
|
||||
// The paths are fixed by the till, which appends `/orders`, `/customers` and
|
||||
// `/catalogue` to whatever base URL a shop enters in Settings. Set that base to
|
||||
// this group — `https://your-host/live/api/v1/pos` — and the three line up.
|
||||
//
|
||||
// Kept in their own group rather than folded into the order routes because a
|
||||
// terminal authenticates as a device, not as a signed-in user, and because
|
||||
// these answer with a bare ack rather than the web app's response envelope.
|
||||
func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
|
||||
|
||||
pos := api.Group("/v1/pos")
|
||||
|
||||
pos.Post("/orders", f.PosController.IngestOrders)
|
||||
pos.Post("/customers", f.PosController.IngestCustomers)
|
||||
pos.Get("/catalogue", f.PosController.Catalogue)
|
||||
|
||||
// Terminal presence, read from Redis. What the rider app's POS board and a
|
||||
// support call both hit — the tills themselves publish health over the
|
||||
// broker rather than posting it here.
|
||||
pos.Get("/health/terminal", f.PosController.TerminalHealth)
|
||||
pos.Get("/health/location", f.PosController.LocationHealth)
|
||||
}
|
||||
@@ -19,4 +19,5 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) {
|
||||
RegisterPartnerRoutes(api, f)
|
||||
RegisterCustomerRoutes(api, f)
|
||||
RegisterCatalogueRoutes(api, f)
|
||||
RegisterPosRoutes(api, f)
|
||||
}
|
||||
|
||||
116
scratch/dbinspect/cleanup.go
Normal file
116
scratch/dbinspect/cleanup.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// cleanup removes everything the end-to-end proof wrote to the live database.
|
||||
//
|
||||
// Three things, in an order that leaves nothing half-undone: the stock the test
|
||||
// bills consumed is returned, the bills themselves are deleted, and the price
|
||||
// that was set to make a product sellable goes back to what it was.
|
||||
//
|
||||
// The two test bills are named explicitly rather than deleted by date or by
|
||||
// "everything in pos_orders" — a table that will hold real takings tomorrow is
|
||||
// not one to run an unbounded DELETE against.
|
||||
const probeMobile = "9840012345"
|
||||
|
||||
var testOrderIDs = []string{
|
||||
"11111111-2222-4333-8444-555555555555", // the HTTP probe
|
||||
"99999999-8888-4777-8666-555555555555", // the MQTT probe
|
||||
}
|
||||
|
||||
func cleanup(db *gorm.DB) {
|
||||
var billIDs []int
|
||||
db.Raw(`SELECT posorderid FROM pos_orders WHERE terminalorderid IN ?`,
|
||||
testOrderIDs).Scan(&billIDs)
|
||||
|
||||
if len(billIDs) == 0 {
|
||||
fmt.Println("no test bills found — nothing to undo")
|
||||
}
|
||||
|
||||
// Stock first. Deleting the bills before returning what they consumed would
|
||||
// leave the ledger short with nothing left to explain why.
|
||||
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 rounded 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 units of product %d\n", qty, c.Productid)
|
||||
}
|
||||
|
||||
if len(billIDs) > 0 {
|
||||
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 test bill(s)\n", len(billIDs))
|
||||
}
|
||||
|
||||
db.Exec(`UPDATE productlocations SET price = 0
|
||||
WHERE tenantid=? AND locationid=? AND productid=?`,
|
||||
tenantID, locationID, productID)
|
||||
fmt.Printf(" product %d price restored to 0\n", productID)
|
||||
|
||||
// The shopper the probes created. Removed only when nothing references it —
|
||||
// a customer row attached to a real order is not test data any more.
|
||||
var referenced int
|
||||
db.Raw(`SELECT COUNT(*) FROM orders WHERE customerid =
|
||||
(SELECT MIN(customerid) FROM customers WHERE contactno = ?)`,
|
||||
probeMobile).Scan(&referenced)
|
||||
if referenced > 0 {
|
||||
fmt.Printf(" customer %s left in place — %d order(s) reference it\n",
|
||||
probeMobile, referenced)
|
||||
} else {
|
||||
res := db.Exec(`DELETE FROM customers WHERE contactno = ?`, probeMobile)
|
||||
fmt.Printf(" removed %d probe customer row(s)\n", res.RowsAffected)
|
||||
}
|
||||
|
||||
var balance 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 tenantid=? AND locationid=? AND productid=?`,
|
||||
tenantID, locationID, productID).Scan(&balance)
|
||||
fmt.Printf(" product %d balance back to %.0f\n", productID, balance)
|
||||
}
|
||||
|
||||
// showCustomer reports whether the registration probe reached the customers
|
||||
// table, and under which id — the question an ack alone cannot answer.
|
||||
func showCustomer(db *gorm.DB, mobile string) {
|
||||
var rows []struct {
|
||||
Customerid int
|
||||
Firstname string
|
||||
Contactno string
|
||||
Applocationid int
|
||||
}
|
||||
db.Raw(`SELECT customerid, COALESCE(firstname,'') AS firstname,
|
||||
COALESCE(contactno,'') AS contactno,
|
||||
COALESCE(applocationid,0) AS applocationid
|
||||
FROM customers WHERE contactno = ?`, mobile).Scan(&rows)
|
||||
|
||||
if len(rows) == 0 {
|
||||
fmt.Printf(" no customer with contactno %s\n", mobile)
|
||||
return
|
||||
}
|
||||
for _, r := range rows {
|
||||
fmt.Printf(" customerid=%d %q contactno=%s applocid=%d\n",
|
||||
r.Customerid, r.Firstname, r.Contactno, r.Applocationid)
|
||||
}
|
||||
}
|
||||
163
scratch/dbinspect/main.go
Normal file
163
scratch/dbinspect/main.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// End-to-end proof for the POS ingest, against the live database.
|
||||
//
|
||||
// Sets a temporary price on ONE product so a bill can be rung, and prints the
|
||||
// exact SQL to undo it. Everything else is read-only.
|
||||
//
|
||||
// go run ./scratch/dbinspect price # set a test price, print the undo
|
||||
// go run ./scratch/dbinspect verify # show the bill and the stock it moved
|
||||
// go run ./scratch/dbinspect restore # put the price back
|
||||
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
|
||||
productID = 6988 // Mysore Banana — 750 units in stock, 8% tax
|
||||
testPrice = 60.00
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
dsn := fmt.Sprintf(
|
||||
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Kolkata",
|
||||
os.Getenv("DB_HOST"), os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"),
|
||||
os.Getenv("DB_NAME"), os.Getenv("DB_PORT"),
|
||||
)
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal("connect:", err)
|
||||
}
|
||||
|
||||
mode := "verify"
|
||||
if len(os.Args) > 1 {
|
||||
mode = os.Args[1]
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "price":
|
||||
var before float64
|
||||
db.Raw(`SELECT COALESCE(price,0) FROM productlocations
|
||||
WHERE tenantid=? AND locationid=? AND productid=?`,
|
||||
tenantID, locationID, productID).Scan(&before)
|
||||
|
||||
if err := db.Exec(`UPDATE productlocations SET price = ?
|
||||
WHERE tenantid=? AND locationid=? AND productid=?`,
|
||||
testPrice, tenantID, locationID, productID).Error; err != nil {
|
||||
log.Fatal("price:", err)
|
||||
}
|
||||
|
||||
fmt.Printf("product %d priced at %.2f (was %.2f)\n", productID, testPrice, before)
|
||||
fmt.Printf("\nUNDO:\n UPDATE productlocations SET price = %.2f\n"+
|
||||
" WHERE tenantid=%d AND locationid=%d AND productid=%d;\n",
|
||||
before, tenantID, locationID, productID)
|
||||
|
||||
case "restore":
|
||||
if err := db.Exec(`UPDATE productlocations SET price = 0
|
||||
WHERE tenantid=? AND locationid=? AND productid=?`,
|
||||
tenantID, locationID, productID).Error; err != nil {
|
||||
log.Fatal("restore:", err)
|
||||
}
|
||||
fmt.Printf("product %d price restored to 0\n", productID)
|
||||
|
||||
case "verify":
|
||||
fmt.Println("=== pos_orders ===")
|
||||
var bills []struct {
|
||||
Posorderid int
|
||||
Terminalorderid string
|
||||
Invoicenumber string
|
||||
Terminalid string
|
||||
Cashiername string
|
||||
Businessdate string
|
||||
Subtotal float64
|
||||
Taxamount float64
|
||||
Roundoff float64
|
||||
Total float64
|
||||
Itemcount int
|
||||
Paymentmode string
|
||||
}
|
||||
db.Raw(`SELECT posorderid, terminalorderid, invoicenumber, terminalid,
|
||||
cashiername, businessdate, subtotal, taxamount, roundoff,
|
||||
total, itemcount, paymentmode
|
||||
FROM pos_orders ORDER BY posorderid DESC LIMIT 5`).Scan(&bills)
|
||||
|
||||
if len(bills) == 0 {
|
||||
fmt.Println(" (none yet)")
|
||||
}
|
||||
for _, b := range bills {
|
||||
fmt.Printf(" #%d %s till=%s cashier=%s date=%s\n",
|
||||
b.Posorderid, b.Invoicenumber, b.Terminalid, b.Cashiername, b.Businessdate)
|
||||
fmt.Printf(" uuid=%s\n", b.Terminalorderid)
|
||||
fmt.Printf(" subtotal=%.2f tax=%.2f roundoff=%.2f total=%.2f items=%d paid=%s\n",
|
||||
b.Subtotal, b.Taxamount, b.Roundoff, b.Total, b.Itemcount, b.Paymentmode)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== pos_order_items ===")
|
||||
var items []struct {
|
||||
Posorderid int
|
||||
Productid int
|
||||
Productname string
|
||||
Quantity float64
|
||||
Unitprice float64
|
||||
Gstrate float64
|
||||
Taxamount float64
|
||||
Linetotal float64
|
||||
}
|
||||
db.Raw(`SELECT posorderid, productid, productname, quantity, unitprice,
|
||||
gstrate, taxamount, linetotal
|
||||
FROM pos_order_items ORDER BY posorderitemid DESC LIMIT 10`).Scan(&items)
|
||||
for _, i := range items {
|
||||
fmt.Printf(" bill#%d %-18.18s qty=%-6.2f @%-8.2f gst=%-6.2f tax=%-7.2f line=%.2f\n",
|
||||
i.Posorderid, i.Productname, i.Quantity, i.Unitprice,
|
||||
i.Gstrate, i.Taxamount, i.Linetotal)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== stock ledger for the test product ===")
|
||||
var moves []struct {
|
||||
Productstockid int
|
||||
Quantity int
|
||||
Stocktype string
|
||||
Stockdate string
|
||||
}
|
||||
db.Raw(`SELECT productstockid, quantity, stocktype,
|
||||
TO_CHAR(stockdate,'YYYY-MM-DD HH24:MI:SS') AS stockdate
|
||||
FROM productstocks
|
||||
WHERE tenantid=? AND locationid=? AND productid=?
|
||||
ORDER BY productstockid DESC LIMIT 5`,
|
||||
tenantID, locationID, productID).Scan(&moves)
|
||||
for _, m := range moves {
|
||||
fmt.Printf(" #%d %-4s qty=%-6d %s\n",
|
||||
m.Productstockid, m.Stocktype, m.Quantity, m.Stockdate)
|
||||
}
|
||||
|
||||
var balance 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 tenantid=? AND locationid=? AND productid=?`,
|
||||
tenantID, locationID, productID).Scan(&balance)
|
||||
fmt.Printf(" balance now: %.0f\n", balance)
|
||||
|
||||
case "cleanup":
|
||||
cleanup(db)
|
||||
|
||||
case "customer":
|
||||
fmt.Println("=== customers matching the uplink probe ===")
|
||||
showCustomer(db, "9840012345")
|
||||
|
||||
default:
|
||||
log.Fatalf("unknown mode %q — use price, verify, restore or cleanup", mode)
|
||||
}
|
||||
}
|
||||
122
scratch/mqttpub/main.go
Normal file
122
scratch/mqttpub/main.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// Publishes a bill over the real broker and waits for the ack, standing in for
|
||||
// a till until one is available to test with.
|
||||
//
|
||||
// go run ./scratch/mqttpub
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
const (
|
||||
locationID = "1135"
|
||||
terminalID = "T4A9"
|
||||
orderID = "99999999-8888-4777-8666-555555555555" // distinct from the HTTP test
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
broker := os.Getenv("MQTT_URL")
|
||||
if broker == "" {
|
||||
broker = "tcp://66.116.225.226:1883"
|
||||
}
|
||||
|
||||
opts := mqtt.NewClientOptions().
|
||||
AddBroker(broker).
|
||||
SetClientID("pos-e2e-probe").
|
||||
SetUsername(os.Getenv("MQTT_USER")).
|
||||
SetPassword(os.Getenv("MQTT_PASSWORD")).
|
||||
SetCleanSession(true)
|
||||
|
||||
client := mqtt.NewClient(opts)
|
||||
if t := client.Connect(); t.Wait() && t.Error() != nil {
|
||||
log.Fatal("connect:", t.Error())
|
||||
}
|
||||
defer client.Disconnect(500)
|
||||
fmt.Println("connected to", broker)
|
||||
|
||||
acks := make(chan []byte, 1)
|
||||
ackTopic := fmt.Sprintf("nearle/pos/%s/%s/ack", locationID, terminalID)
|
||||
if t := client.Subscribe(ackTopic, 1, func(_ mqtt.Client, m mqtt.Message) {
|
||||
acks <- m.Payload()
|
||||
}); t.Wait() && t.Error() != nil {
|
||||
log.Fatal("subscribe:", t.Error())
|
||||
}
|
||||
fmt.Println("listening on", ackTopic)
|
||||
|
||||
batch := map[string]any{
|
||||
"schema": 1,
|
||||
"batch_id": "batch-mqtt-0001",
|
||||
"store_id": locationID,
|
||||
"terminal_id": terminalID,
|
||||
"sent_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"orders": []map[string]any{{
|
||||
"id": orderID,
|
||||
"invoice_number": "INV-2608-T4A9-00002",
|
||||
"created_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"terminal_id": terminalID,
|
||||
"cashier": "Divya",
|
||||
"customer": map[string]any{"id": "c-2", "mobile": "9840099999", "name": "Ravi"},
|
||||
"subtotal": 60.0,
|
||||
"discount": 0.0,
|
||||
"promos": []any{},
|
||||
"tax": 4.44,
|
||||
"tax_breakdown": map[string]float64{"0.08": 4.44},
|
||||
"round_off": 0.0,
|
||||
"total": 60.0,
|
||||
"points_earned": 0,
|
||||
"points_redeemed": 0,
|
||||
"payments": []map[string]any{{"method": "upi", "amount": 60.0, "reference": "TESTUPI"}},
|
||||
"items": []map[string]any{{
|
||||
"product_id": "6988", "barcode": "6988", "name": "Mysore Banana",
|
||||
"quantity": 1, "unit_price": 60.0, "discount": 0.0,
|
||||
"gst_rate": 0.08, "tax": 4.44, "line_total": 60.0,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(batch)
|
||||
orderTopic := fmt.Sprintf("nearle/pos/%s/%s/order", locationID, terminalID)
|
||||
if t := client.Publish(orderTopic, 1, false, body); t.Wait() && t.Error() != nil {
|
||||
log.Fatal("publish:", t.Error())
|
||||
}
|
||||
fmt.Println("published a bill to", orderTopic)
|
||||
|
||||
// The same 20 seconds a real till waits before giving up and re-sending.
|
||||
select {
|
||||
case payload := <-acks:
|
||||
fmt.Println("\nACK RECEIVED:")
|
||||
var pretty map[string]any
|
||||
_ = json.Unmarshal(payload, &pretty)
|
||||
out, _ := json.MarshalIndent(pretty, " ", " ")
|
||||
fmt.Println(" " + string(out))
|
||||
case <-time.After(20 * time.Second):
|
||||
fmt.Println("\nNO ACK within 20s — a real till would keep the bill and send it again")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// A heartbeat too, so the Redis presence path is exercised.
|
||||
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": 2, "today_amount": 170.0,
|
||||
"printer_reachable": false,
|
||||
"reported_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
healthTopic := fmt.Sprintf("nearle/pos/%s/%s/health", locationID, terminalID)
|
||||
if t := client.Publish(healthTopic, 1, false, health); t.Wait() && t.Error() != nil {
|
||||
log.Fatal("publish health:", t.Error())
|
||||
}
|
||||
fmt.Println("\npublished a heartbeat to", healthTopic)
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
55
services/posService.go
Normal file
55
services/posService.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"nearle/models"
|
||||
"nearle/repositories"
|
||||
)
|
||||
|
||||
type PosService interface {
|
||||
IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error)
|
||||
IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error)
|
||||
Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error)
|
||||
|
||||
// RecordHealth stores one heartbeat. Never acknowledged back to the till:
|
||||
// presence is a fire-and-forget signal, and a terminal that stopped selling
|
||||
// because its heartbeat failed would be a worse outcome than a blank board.
|
||||
RecordHealth(ctx context.Context, health models.PosHealth) error
|
||||
|
||||
TerminalHealth(ctx context.Context, terminalID string) (map[string]string, error)
|
||||
LocationHealth(ctx context.Context, locationID string) ([]map[string]string, error)
|
||||
}
|
||||
|
||||
type posService struct {
|
||||
repo repositories.PosRepository
|
||||
presence repositories.PosPresenceRepository
|
||||
}
|
||||
|
||||
func NewPosService(repo repositories.PosRepository, presence repositories.PosPresenceRepository) PosService {
|
||||
return &posService{repo: repo, presence: presence}
|
||||
}
|
||||
|
||||
func (s *posService) RecordHealth(ctx context.Context, health models.PosHealth) error {
|
||||
return s.presence.Record(ctx, health)
|
||||
}
|
||||
|
||||
func (s *posService) TerminalHealth(ctx context.Context, terminalID string) (map[string]string, error) {
|
||||
return s.presence.Terminal(ctx, terminalID)
|
||||
}
|
||||
|
||||
func (s *posService) LocationHealth(ctx context.Context, locationID string) ([]map[string]string, error) {
|
||||
return s.presence.Location(ctx, locationID)
|
||||
}
|
||||
|
||||
func (s *posService) IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error) {
|
||||
return s.repo.IngestOrders(batch)
|
||||
}
|
||||
|
||||
func (s *posService) IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error) {
|
||||
return s.repo.IngestCustomers(batch)
|
||||
}
|
||||
|
||||
func (s *posService) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) {
|
||||
return s.repo.Catalogue(storeID, since, page, pageSize)
|
||||
}
|
||||
Reference in New Issue
Block a user