Ingest counter sales from the POS terminals, over MQTT and HTTP
A till holds every bill in its own SQLite database and keeps it for seven days after we acknowledge it, marking one synced only when its id comes back in an ack. Everything here follows from that. Silence is not acceptance, so a failing ingest publishes nothing at all and the terminal simply sends again. A duplicate is a success, because at-least-once delivery means a lost ack legitimately re-delivers bills we already hold, and calling those failures would strand a day of takings on the till. Deduplication is a unique index on the terminal's UUID plus an advisory lock held for the transaction. Bills land in pos_orders / pos_order_items rather than orders: a counter bill carries a cashier, a terminal, a rounding adjustment, promos, loyalty movement and a payment split that orders has nowhere to put, and forcing one into the other loses whatever does not fit. Stock is *not* split — a counter sale writes the same productstocks rows an app order does, through helpers extracted from createOrderTx so the rule that prevents overselling has one implementation rather than two. GetRevenueSummary and GetSalesSummary were extended to union the new table in; any new report has to remember the same. Terminal health goes to Redis under a 90-second TTL, sharing the instance the express backend uses. A heartbeat is a fact with an expiry date: a till that loses power stops refreshing and ages off the board by itself, where a Postgres row would need ~288k writes a day and a reaper. Proven end to end against the live estate before commit: a bill over HTTP and one over the real Mosquitto broker, the same bill three times producing one row and one stock movement, and a heartbeat arriving on the health endpoint. All probe data was removed afterwards. Four things that only surfaced against real data. An unset jsonb column failed the very first bill. Product SKUs are unusable as barcodes — 6,245 products share 93 SKUs and "1" covers 5,794 of them — against the till's unique index, so barcodes fall back to the product id. A taxpercent of -1 exists and would have put negative GST in a filed slab. And a product with id 0 exists, which can never be billed and is now skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
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 {
|
type Facade struct {
|
||||||
UserController *controllers.UserController
|
UserController *controllers.UserController
|
||||||
ProductController *controllers.ProductController
|
ProductController *controllers.ProductController
|
||||||
OrderController *controllers.OrderController
|
OrderController *controllers.OrderController
|
||||||
DeliveriesController *controllers.DeliveriesController
|
DeliveriesController *controllers.DeliveriesController
|
||||||
UtilsController *controllers.UtilsController
|
UtilsController *controllers.UtilsController
|
||||||
TenantController *controllers.TenantController
|
TenantController *controllers.TenantController
|
||||||
PartnerController *controllers.PartnerController
|
PartnerController *controllers.PartnerController
|
||||||
CustomerController *controllers.CustomerController
|
CustomerController *controllers.CustomerController
|
||||||
StockRequestController *controllers.StockRequestController
|
StockRequestController *controllers.StockRequestController
|
||||||
CatalogueController *controllers.CatalogueController
|
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.
|
// 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)
|
stockRequestService := services.NewStockRequestService(stockRequestRepo, productService)
|
||||||
stockRequestController := controllers.NewStockRequestController(stockRequestService)
|
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{
|
return &Facade{
|
||||||
UserController: userController,
|
UserController: userController,
|
||||||
ProductController: productController,
|
ProductController: productController,
|
||||||
OrderController: orderController,
|
OrderController: orderController,
|
||||||
DeliveriesController: deliveriesController,
|
DeliveriesController: deliveriesController,
|
||||||
UtilsController: utilsController,
|
UtilsController: utilsController,
|
||||||
TenantController: tenantController,
|
TenantController: tenantController,
|
||||||
PartnerController: partnerController,
|
PartnerController: partnerController,
|
||||||
CustomerController: customerController,
|
CustomerController: customerController,
|
||||||
StockRequestController: stockRequestController,
|
StockRequestController: stockRequestController,
|
||||||
CatalogueController: catalogueController,
|
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
|
go 1.24
|
||||||
|
|
||||||
toolchain go1.24.0
|
require (
|
||||||
|
firebase.google.com/go v3.13.0+incompatible
|
||||||
require gorm.io/gorm v1.25.10
|
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 (
|
require (
|
||||||
cloud.google.com/go v0.110.7 // indirect
|
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/iam v1.1.1 // indirect
|
||||||
cloud.google.com/go/longrunning v0.5.1 // indirect
|
cloud.google.com/go/longrunning v0.5.1 // indirect
|
||||||
cloud.google.com/go/storage v1.30.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/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/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/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/configsources v1.4.30 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.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/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/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/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/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/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/ssooidc v1.37.1 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.44.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/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/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/gofiber/utils v0.0.10 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/golang/protobuf v1.5.3 // 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/s2a-go v0.1.7 // indirect
|
||||||
github.com/google/uuid v1.4.0 // indirect
|
github.com/google/uuid v1.4.0 // indirect
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.1 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.1 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
|
||||||
github.com/gorilla/schema v1.1.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/hashicorp/hcl v1.0.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/joho/godotenv v1.5.1 // indirect
|
github.com/klauspost/compress v1.19.0 // indirect
|
||||||
github.com/klauspost/compress v1.17.2 // indirect
|
|
||||||
github.com/magiconair/properties v1.8.7 // indirect
|
github.com/magiconair/properties v1.8.7 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.4 // 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/locafero v0.3.0 // indirect
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.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/fasthttp v1.50.0 // indirect
|
||||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||||
go.opencensus.io v0.24.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
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/crypto v0.31.0 // indirect
|
golang.org/x/crypto v0.31.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
||||||
golang.org/x/net v0.21.0 // indirect
|
golang.org/x/net v0.33.0 // indirect
|
||||||
golang.org/x/oauth2 v0.12.0 // indirect
|
|
||||||
golang.org/x/sync v0.10.0 // indirect
|
golang.org/x/sync v0.10.0 // indirect
|
||||||
golang.org/x/time v0.3.0 // indirect
|
golang.org/x/time v0.3.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // 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/appengine v1.6.7 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb // indirect
|
google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb // indirect
|
||||||
google.golang.org/genproto/googleapis/api 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/grpc v1.58.2 // indirect
|
||||||
google.golang.org/protobuf v1.31.0 // indirect
|
google.golang.org/protobuf v1.31.0 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
gorm.io/driver/postgres v1.6.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -99,5 +105,4 @@ require (
|
|||||||
golang.org/x/sys v0.28.0 // indirect
|
golang.org/x/sys v0.28.0 // indirect
|
||||||
golang.org/x/text v0.21.0 // indirect
|
golang.org/x/text v0.21.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // 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/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 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
|
||||||
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
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/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/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/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
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.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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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.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.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
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 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-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-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 h1:QRUPvPmr8ijQuGo1MgupHBn8E+wW0IKqiOvIZPtV70o=
|
||||||
github.com/gofiber/fiber v1.14.6/go.mod h1:Yw2ekF1YDPreO9V6TMYjynu94xRxZBdaa8X5HhHsjCM=
|
github.com/gofiber/fiber v1.14.6/go.mod h1:Yw2ekF1YDPreO9V6TMYjynu94xRxZBdaa8X5HhHsjCM=
|
||||||
github.com/gofiber/fiber/v2 v2.50.0 h1:ia0JaB+uw3GpNSCR5nvC5dsaxXjRU5OEu36aytx+zGw=
|
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.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.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.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
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 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.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.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-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-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
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/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 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY=
|
||||||
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
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.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/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
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/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/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.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||||
github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4=
|
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||||
github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
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/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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/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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
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.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.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
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 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ=
|
||||||
github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U=
|
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=
|
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.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.32/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/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.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
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.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
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 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
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=
|
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-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-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.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 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
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=
|
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-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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
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.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
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/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
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-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/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-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-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.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 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
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=
|
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-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.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.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 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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=
|
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.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/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.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 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
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=
|
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=
|
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 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-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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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/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 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
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 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
|
||||||
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
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=
|
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"
|
"log"
|
||||||
"nearle/db"
|
"nearle/db"
|
||||||
"nearle/facade"
|
"nearle/facade"
|
||||||
|
"nearle/messaging"
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
"nearle/routes"
|
"nearle/routes"
|
||||||
"os"
|
"os"
|
||||||
@@ -39,13 +40,36 @@ func main() {
|
|||||||
db.Connect()
|
db.Connect()
|
||||||
fmt.Println("✅ Database connections established!")
|
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
|
// Ensure schema is updated
|
||||||
db.DB.AutoMigrate(&models.StockRequest{})
|
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)
|
f := facade.NewFacade(db.DB, db.CatalogueDB)
|
||||||
|
|
||||||
routes.RegisterRoutes(app, f)
|
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
|
// Start server
|
||||||
go func() {
|
go func() {
|
||||||
if err := app.Listen(":1122"); err != nil {
|
if err := app.Listen(":1122"); err != nil {
|
||||||
@@ -53,7 +77,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
gracefulShutdown()
|
gracefulShutdown(posMqtt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectDBMiddleware(c *fiber.Ctx) error {
|
func selectDBMiddleware(c *fiber.Ctx) error {
|
||||||
@@ -78,13 +102,18 @@ func selectDBMiddleware(c *fiber.Ctx) error {
|
|||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
func gracefulShutdown() {
|
func gracefulShutdown(posMqtt *messaging.PosMqttConsumer) {
|
||||||
c := make(chan os.Signal, 1)
|
c := make(chan os.Signal, 1)
|
||||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||||
|
|
||||||
<-c
|
<-c
|
||||||
fmt.Println("\nShutting down gracefully...")
|
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
|
// Normally: close db.DB_DEV and db.DB_LIVE
|
||||||
// Example:
|
// Example:
|
||||||
// closeDB(db.DB_DEV)
|
// 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 {
|
if err := r.db.Raw(overallQuery, overallParams...).Scan(&overallRev).Error; err != nil {
|
||||||
return nil, err
|
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
|
// 4. Fetch revenue details by location
|
||||||
locationQuery := `
|
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 {
|
if err := r.db.Raw(locationQuery, locParams...).Scan(&locRevenues).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if locRevenues == nil {
|
if locRevenues == nil {
|
||||||
locRevenues = []models.LocationRevenueDetails{}
|
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
|
summary.LocationRevenue = locRevenues
|
||||||
|
|
||||||
return &summary, nil
|
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) {
|
func (r *orderRepository) GetDistinctLocations() ([]models.OrderInsight, error) {
|
||||||
var locations []models.OrderInsight
|
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) {
|
func (r *orderRepository) GetSalesSummary(tid, lid int, fdate, tdate string) (*models.SalesSummaryResponse, error) {
|
||||||
var summary models.SalesSummaryResponse
|
var summary models.SalesSummaryResponse
|
||||||
|
|
||||||
whereClause := "tenantid = ? AND orderstatus IN ('delivered', 'completed') AND configid = 1"
|
whereClause := "tenantid = ? AND orderstatus IN ('delivered', 'completed') AND configid = 1"
|
||||||
var params []interface{}
|
var params []interface{}
|
||||||
params = append(params, tid)
|
params = append(params, tid)
|
||||||
|
|
||||||
if lid != 0 {
|
if lid != 0 {
|
||||||
whereClause += " AND locationid = ?"
|
whereClause += " AND locationid = ?"
|
||||||
params = append(params, lid)
|
params = append(params, lid)
|
||||||
}
|
}
|
||||||
|
|
||||||
if fdate != "" && tdate != "" {
|
if fdate != "" && tdate != "" {
|
||||||
whereClause += " AND orderdate::date BETWEEN ? AND ?"
|
whereClause += " AND orderdate::date BETWEEN ? AND ?"
|
||||||
params = append(params, fdate, tdate)
|
params = append(params, fdate, tdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
totalsQuery := fmt.Sprintf(`
|
totalsQuery := fmt.Sprintf(`
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(COALESCE(ordervalue, 0) + COALESCE(orderamount, 0) + COALESCE(deliveryamt, 0)), 0) AS total_revenue,
|
COALESCE(SUM(COALESCE(ordervalue, 0) + COALESCE(orderamount, 0) + COALESCE(deliveryamt, 0)), 0) AS total_revenue,
|
||||||
COUNT(orderheaderid) AS total_orders
|
COUNT(orderheaderid) AS total_orders
|
||||||
FROM orders
|
FROM orders
|
||||||
WHERE %s`, whereClause)
|
WHERE %s`, whereClause)
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
TotalRevenue float64
|
TotalRevenue float64
|
||||||
TotalOrders int
|
TotalOrders int
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.db.Raw(totalsQuery, params...).Scan(&result).Error; err != nil {
|
if err := r.db.Raw(totalsQuery, params...).Scan(&result).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
summary.TotalRevenue = result.TotalRevenue
|
// Counter sales, folded in before the average is taken — computing it from
|
||||||
summary.TotalOrders = result.TotalOrders
|
// 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 {
|
if summary.TotalOrders > 0 {
|
||||||
summary.AverageOrderValue = summary.TotalRevenue / float64(summary.TotalOrders)
|
summary.AverageOrderValue = summary.TotalRevenue / float64(summary.TotalOrders)
|
||||||
}
|
}
|
||||||
|
|
||||||
chartQuery := fmt.Sprintf(`
|
chartQuery := fmt.Sprintf(`
|
||||||
SELECT
|
SELECT
|
||||||
CAST(orderdate AS DATE) AS date,
|
CAST(orderdate AS DATE) AS date,
|
||||||
@@ -856,17 +1022,21 @@ func (r *orderRepository) GetSalesSummary(tid, lid int, fdate, tdate string) (*m
|
|||||||
WHERE %s
|
WHERE %s
|
||||||
GROUP BY CAST(orderdate AS DATE)
|
GROUP BY CAST(orderdate AS DATE)
|
||||||
ORDER BY date ASC`, whereClause)
|
ORDER BY date ASC`, whereClause)
|
||||||
|
|
||||||
var chartData []models.SalesSummaryChartData
|
var chartData []models.SalesSummaryChartData
|
||||||
if err := r.db.Raw(chartQuery, params...).Scan(&chartData).Error; err != nil {
|
if err := r.db.Raw(chartQuery, params...).Scan(&chartData).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if chartData == nil {
|
if chartData == nil {
|
||||||
chartData = []models.SalesSummaryChartData{}
|
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
|
summary.ChartData = chartData
|
||||||
|
|
||||||
var topLocations []models.SalesSummaryTopLocation
|
var topLocations []models.SalesSummaryTopLocation
|
||||||
if lid == 0 {
|
if lid == 0 {
|
||||||
topWhere := "o.tenantid = ? AND o.orderstatus IN ('delivered', 'completed') AND o.configid = 1"
|
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 ?"
|
topWhere += " AND o.orderdate::date BETWEEN ? AND ?"
|
||||||
topParams = append(topParams, fdate, tdate)
|
topParams = append(topParams, fdate, tdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanTopLocQuery := fmt.Sprintf(`
|
cleanTopLocQuery := fmt.Sprintf(`
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(l.locationname, 'Unknown') AS locationname,
|
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
|
GROUP BY l.locationid, l.locationname
|
||||||
ORDER BY revenue DESC
|
ORDER BY revenue DESC
|
||||||
LIMIT 5`, topWhere)
|
LIMIT 5`, topWhere)
|
||||||
|
|
||||||
if err := r.db.Raw(cleanTopLocQuery, topParams...).Scan(&topLocations).Error; err != nil {
|
if err := r.db.Raw(cleanTopLocQuery, topParams...).Scan(&topLocations).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if topLocations == nil {
|
if topLocations == nil {
|
||||||
topLocations = []models.SalesSummaryTopLocation{}
|
topLocations = []models.SalesSummaryTopLocation{}
|
||||||
}
|
}
|
||||||
summary.TopLocations = topLocations
|
summary.TopLocations = topLocations
|
||||||
|
|
||||||
return &summary, nil
|
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
|
// 🛠️ Step 0: Lock every (tenantid, locationid, productid) row this order
|
||||||
// touches before checking availability. Without this, two concurrent
|
// touches, then check availability under those locks.
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// Locks are acquired in a fixed (productid, locationid) order so that
|
// Both now live in stockLedger.go, shared with the POS ingest — one
|
||||||
// two orders sharing overlapping products always contend for them in
|
// implementation of the rule that stops overselling, rather than one per
|
||||||
// the same sequence, avoiding a lock-ordering deadlock between the two
|
// caller waiting to drift out of step with the others. Behaviour here is
|
||||||
// transactions (as opposed to just making each individually block).
|
// unchanged, including legacyOrderQty's truncate-then-floor-at-1, which is
|
||||||
type lockTarget struct {
|
// what this path has always done.
|
||||||
productid int
|
lines := make([]stockLine, 0, len(data.Items))
|
||||||
locationid int
|
|
||||||
}
|
|
||||||
seen := make(map[lockTarget]bool)
|
|
||||||
locks := make([]lockTarget, 0, len(data.Items))
|
|
||||||
for _, item := range data.Items {
|
for _, item := range data.Items {
|
||||||
itemLocID := item.Locationid
|
itemLocID := item.Locationid
|
||||||
if itemLocID == 0 {
|
if itemLocID == 0 {
|
||||||
itemLocID = locID
|
itemLocID = locID
|
||||||
}
|
}
|
||||||
lt := lockTarget{productid: item.Productid, locationid: itemLocID}
|
lines = append(lines, stockLine{
|
||||||
if !seen[lt] {
|
Productid: item.Productid,
|
||||||
seen[lt] = true
|
Locationid: itemLocID,
|
||||||
locks = append(locks, lt)
|
Productname: item.Productname,
|
||||||
}
|
Quantity: item.Orderqty,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
sort.Slice(locks, func(a, b int) bool {
|
|
||||||
if locks[a].productid != locks[b].productid {
|
if err := lockStockRows(tx, data.Tenantid, lines); err != nil {
|
||||||
return locks[a].productid < locks[b].productid
|
tx.Rollback()
|
||||||
}
|
return models.Orders{}, err
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🛠️ Step 1: Pre-validate stock availability for all items before placing order
|
// 🛠️ Step 1: Pre-validate stock availability for all items before placing order
|
||||||
for _, item := range data.Items {
|
if err := assertStockAvailable(tx, data.Tenantid, lines, func(l stockLine) int {
|
||||||
itemLocID := item.Locationid
|
return legacyOrderQty(l.Quantity)
|
||||||
if itemLocID == 0 {
|
}); err != nil {
|
||||||
itemLocID = locID
|
tx.Rollback()
|
||||||
}
|
return models.Orders{}, err
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🛠️ Step 2: Create Order Header
|
// 🛠️ Step 2: Create Order Header
|
||||||
@@ -1331,28 +1455,17 @@ func (r *orderRepository) createOrderTx(tx *gorm.DB, data models.Orders) (models
|
|||||||
return models.Orders{}, err
|
return models.Orders{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
qty := int(item.Orderqty)
|
// Writes the "out" entry and re-derives the location's availability
|
||||||
if qty <= 0 {
|
// flag from the balance it produced.
|
||||||
qty = 1
|
if err := recordStockOut(
|
||||||
}
|
tx,
|
||||||
|
data.Tenantid,
|
||||||
stock := models.Productstock{
|
stockLine{Productid: item.Productid, Locationid: itemLocID},
|
||||||
Tenantid: data.Tenantid,
|
legacyOrderQty(item.Orderqty),
|
||||||
Stockdate: time.Now(),
|
); err != nil {
|
||||||
Locationid: itemLocID,
|
|
||||||
Productid: item.Productid,
|
|
||||||
Quantity: qty,
|
|
||||||
Stocktype: "out",
|
|
||||||
Status: "Active",
|
|
||||||
}
|
|
||||||
if err := tx.Table("productstocks").Create(&stock).Error; err != nil {
|
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return models.Orders{}, err
|
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.
|
// Deliberately not committed: the caller owns the transaction boundary.
|
||||||
@@ -2138,7 +2251,7 @@ func (r *orderRepository) GetTimeSeries(tenantID, locationID int, granularity, f
|
|||||||
|
|
||||||
var locFilter, dateFilter string
|
var locFilter, dateFilter string
|
||||||
var params []interface{}
|
var params []interface{}
|
||||||
|
|
||||||
// subquery params
|
// subquery params
|
||||||
params = append(params, tenantID)
|
params = append(params, tenantID)
|
||||||
if locationID != 0 {
|
if locationID != 0 {
|
||||||
@@ -2149,7 +2262,7 @@ func (r *orderRepository) GetTimeSeries(tenantID, locationID int, granularity, f
|
|||||||
dateFilter = " AND o2.orderdate::date BETWEEN ? AND ?"
|
dateFilter = " AND o2.orderdate::date BETWEEN ? AND ?"
|
||||||
params = append(params, fromDate, toDate)
|
params = append(params, fromDate, toDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// main query params
|
// main query params
|
||||||
params = append(params, tenantID)
|
params = append(params, tenantID)
|
||||||
if locationID != 0 {
|
if locationID != 0 {
|
||||||
@@ -2194,7 +2307,7 @@ func (r *orderRepository) GetTimeSeries(tenantID, locationID int, granularity, f
|
|||||||
if err := r.db.Raw(query, params...).Scan(&data).Error; err != nil {
|
if err := r.db.Raw(query, params...).Scan(&data).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if data == nil {
|
if data == nil {
|
||||||
data = []models.TimeSeriesData{}
|
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)
|
RegisterPartnerRoutes(api, f)
|
||||||
RegisterCustomerRoutes(api, f)
|
RegisterCustomerRoutes(api, f)
|
||||||
RegisterCatalogueRoutes(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