Compare commits
13 Commits
27fbbf0422
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5864204d32 | ||
|
|
d0c3cb751e | ||
|
|
11595ad415 | ||
|
|
ec672a3087 | ||
| bddd8fa265 | |||
|
|
8aa4d86eb6 | ||
|
|
b0caacd90a | ||
|
|
b9f389fcdf | ||
|
|
1e3386fac8 | ||
|
|
fc81df14e4 | ||
|
|
8709556704 | ||
|
|
ef647d3395 | ||
|
|
64a219e7da |
21
.env
21
.env
@@ -1,21 +0,0 @@
|
||||
APP_PORT=1009
|
||||
DB_HOST=66.116.207.225
|
||||
DB_PORT=5433
|
||||
DB_NAME=nearledb
|
||||
DB_USER=admin
|
||||
DB_PASSWORD="Package@123#"
|
||||
|
||||
# --- Catalogue Postgres / pgvector (separate DB, read-only integration) ---
|
||||
CATALOGUE_DB_HOST=31.97.228.132
|
||||
CATALOGUE_DB_PORT=6054
|
||||
CATALOGUE_DB_NAME=pgvector
|
||||
CATALOGUE_DB_USER=admin
|
||||
CATALOGUE_DB_PASSWORD="'Package@321#'"
|
||||
|
||||
# --- DigitalOcean Spaces (S3-compatible), catalogue product images ---
|
||||
USE_S3=true
|
||||
S3_ACCESS_KEY=DO801G8Q8JAZKF49U3WJ
|
||||
S3_SECRET_KEY=lBQExYfkVqH+ybmGVmQH5MkThBbrIohA/VQLgcPUvug
|
||||
S3_ENDPOINT=https://nearle.sgp1.digitaloceanspaces.com
|
||||
S3_BUCKET=nearle
|
||||
S3_REGION=sgp1
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -48,3 +48,11 @@ Thumbs.db
|
||||
*.mov
|
||||
*.wmv
|
||||
|
||||
|
||||
# Local configuration. Tracked until 2026-08-03, which put the database
|
||||
# credentials in this repository's history — removing it from the index stops
|
||||
# that getting worse, but the existing history still has them and the password
|
||||
# should be rotated.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
458
POS_API.md
Normal file
458
POS_API.md
Normal file
@@ -0,0 +1,458 @@
|
||||
# POS integration — handover
|
||||
|
||||
Everything a developer needs to work on, extend or debug the in-store POS
|
||||
integration. Companion to [`POS_TERMINAL_INGEST.md`](POS_TERMINAL_INGEST.md),
|
||||
which covers deployment and broker setup.
|
||||
|
||||
Base path for everything below: **`/live/api/v1/pos`**
|
||||
|
||||
---
|
||||
|
||||
## 1. What this is
|
||||
|
||||
Retail tills run a Flutter POS app. Each one holds its own SQLite database and
|
||||
keeps working with no network at all. When a connection is available it
|
||||
publishes completed bills to an MQTT broker; a consumer in this backend commits
|
||||
them to Postgres and acknowledges.
|
||||
|
||||
```
|
||||
Cashier completes sale
|
||||
│
|
||||
▼
|
||||
Till's SQLite ───────────────────── one transaction, before any network
|
||||
sync_status = 0 survives crash, power cut, dead wifi
|
||||
│
|
||||
▼
|
||||
MQTT broker ──────────────────────── transit only, holds nothing you can rely on
|
||||
nearle/pos/{loc}/{terminal}/order
|
||||
│
|
||||
▼
|
||||
fiesta consumer (messaging/posmqtt.go)
|
||||
│
|
||||
▼
|
||||
POSTGRES ─────────────────────────── the permanent record
|
||||
pos_orders, pos_order_items
|
||||
productstocks (stock deducted here)
|
||||
│
|
||||
▼
|
||||
ack → nearle/pos/{loc}/{terminal}/ack
|
||||
│
|
||||
▼
|
||||
Till marks it synced, keeps its copy 7 more days, then purges
|
||||
```
|
||||
|
||||
**Health** takes a separate path: every till publishes a heartbeat every 30
|
||||
seconds, which lands in **Redis** under a 90-second TTL. Never in Postgres.
|
||||
|
||||
---
|
||||
|
||||
## 2. The four rules everything rests on
|
||||
|
||||
Break any of these and shops lose money. They are not stylistic.
|
||||
|
||||
**1. Only an application acknowledgement counts.**
|
||||
A broker PUBACK means "I hold these bytes". It is not evidence the database
|
||||
accepted anything. The ack is published *after* the transaction commits, never
|
||||
from a handler that has merely queued the work.
|
||||
|
||||
**2. Silence is not acceptance.**
|
||||
No ack, an empty ack, 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.
|
||||
|
||||
**3. A duplicate is a success.**
|
||||
Delivery is at-least-once. A lost ack makes a terminal re-send bills we already
|
||||
hold. Reporting those as failures would strand a day of takings. Deduplication
|
||||
is a unique index on `pos_orders.terminalorderid` — the UUID minted at the till
|
||||
— plus a Postgres advisory lock held for the transaction.
|
||||
|
||||
**4. Store and terminal come from the topic, never the body.**
|
||||
A till that could name its own store in a payload could redirect another
|
||||
counter's acknowledgements.
|
||||
|
||||
---
|
||||
|
||||
## 3. Endpoints written by a terminal
|
||||
|
||||
These answer with a **bare body**, not the usual `{code, message, status}`
|
||||
envelope — the till reads `accepted` from the top level and marks a bill synced
|
||||
only if its id is there. Wrapping it would leave every terminal queueing for
|
||||
ever.
|
||||
|
||||
### `POST /orders`
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": 1,
|
||||
"batch_id": "9f1c…",
|
||||
"store_id": "1135",
|
||||
"terminal_id": "T4A9",
|
||||
"orders": [{
|
||||
"id": "99999999-8888-4777-8666-555555555555",
|
||||
"invoice_number": "INV-2608-T4A9-00002",
|
||||
"created_at": "2026-08-03T17:29:00Z",
|
||||
"cashier": "Divya",
|
||||
"customer": {"id": "…", "mobile": "9840099999", "name": "Ravi"},
|
||||
"subtotal": 60.0, "discount": 0.0, "tax": 4.44,
|
||||
"tax_breakdown": {"0.08": 4.44},
|
||||
"round_off": 0.0, "total": 60.0,
|
||||
"points_earned": 0, "points_redeemed": 0,
|
||||
"payments": [{"method": "upi", "amount": 60.0, "reference": "TXN123"}],
|
||||
"items": [{
|
||||
"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
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "batch_id": "9f1c…", "accepted": ["99999999-…"], "rejected": {} }
|
||||
```
|
||||
|
||||
Status codes carry the other half of the contract:
|
||||
|
||||
| Code | Meaning | Terminal does |
|
||||
|---|---|---|
|
||||
| `200` | batch processed; ack says which bills landed | marks the named ids synced |
|
||||
| `4xx` | the request is wrong — unknown outlet, bad store id | **halts** and shows a person |
|
||||
| `5xx` | outcome unknown | keeps everything, retries with backoff |
|
||||
|
||||
### `POST /customers`
|
||||
|
||||
Same envelope with a `customers` array. **Insert-if-absent on id** — never an
|
||||
update, so a profile corrected at head office is not reverted by a terminal
|
||||
replaying an old capture.
|
||||
|
||||
The id is a **UUIDv5 over the normalised ten-digit mobile**, so two tills
|
||||
registering the same shopper independently produce the same row. Do not
|
||||
reassign it.
|
||||
|
||||
No loyalty figures travel upward — points and spend are derived from the bill
|
||||
stream, which is idempotent and sees every counter.
|
||||
|
||||
---
|
||||
|
||||
## 4. GET endpoints — for the web app
|
||||
|
||||
**These use the normal `{code, message, status, details}` envelope.**
|
||||
|
||||
### `GET /sales` — bills for an outlet
|
||||
|
||||
```bash
|
||||
curl "$BASE/sales?locationid=1135&fromdate=2026-08-01&todate=2026-08-03"
|
||||
```
|
||||
|
||||
| Parameter | |
|
||||
|---|---|
|
||||
| `locationid` | **required** — the authorisation boundary |
|
||||
| `fromdate`, `todate` | `YYYY-MM-DD`, matched on `businessdate` |
|
||||
| `terminalid` | e.g. `T4A9` |
|
||||
| `cashiername` | exact match |
|
||||
| `paymentmode` | `cash`, `card`, `upi`, `wallet` |
|
||||
| `pageno` | 0-based, default 0 |
|
||||
| `pagesize` | default 50, max 500 |
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "status": true,
|
||||
"details": {
|
||||
"total": 137, "pageno": 0, "pagesize": 50,
|
||||
"bills": [{
|
||||
"posorderid": 7,
|
||||
"terminalorderid": "99999999-8888-4777-8666-555555555555",
|
||||
"invoicenumber": "INV-2608-T4A9-00002",
|
||||
"tenantid": 1087, "locationid": 1135,
|
||||
"terminalid": "T4A9", "cashiername": "Divya",
|
||||
"customerid": 6847, "customermobile": "9840099999", "customername": "Ravi",
|
||||
"billedat": "2026-08-03T17:29:00Z",
|
||||
"businessdate": "2026-08-03",
|
||||
"subtotal": 60, "discount": 0, "taxamount": 4.44,
|
||||
"roundoff": 0, "total": 60,
|
||||
"pointsearned": 0, "pointsredeemed": 0,
|
||||
"itemcount": 1, "paymentmode": "upi",
|
||||
"paymentsjson": "[{\"method\":\"upi\",\"amount\":60,\"reference\":\"TXN123\"}]",
|
||||
"promosjson": "[]",
|
||||
"taxbreakdownjson": "{\"0.08\":4.44}",
|
||||
"batchid": "batch-mqtt-0001",
|
||||
"receivedat": "2026-08-03T17:29:11Z"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Line items are **not** included — a page of 50 bills would drag hundreds of rows
|
||||
behind it and a list screen shows none of them. Use `/sales/detail`.
|
||||
|
||||
Ordered by `billedat` descending, not by id: a backlog uploaded after an outage
|
||||
arrives out of order, and sorting by arrival would interleave yesterday's bills
|
||||
through today's.
|
||||
|
||||
### `GET /sales/detail` — one bill with its lines
|
||||
|
||||
```bash
|
||||
curl "$BASE/sales/detail?locationid=1135&reference=INV-2608-T4A9-00002"
|
||||
```
|
||||
|
||||
`reference` accepts **any of three**: the terminal's order UUID, the invoice
|
||||
number, or the `posorderid`. A support call starts from whichever the caller
|
||||
happens to be looking at.
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "status": true,
|
||||
"details": {
|
||||
"posorderid": 7,
|
||||
"invoicenumber": "INV-2608-T4A9-00002",
|
||||
"…": "all the fields above, plus:",
|
||||
"items": [{
|
||||
"posorderitemid": 12, "posorderid": 7,
|
||||
"productid": 6988, "productname": "Mysore Banana",
|
||||
"barcode": "6988", "unitname": "kg",
|
||||
"quantity": 1, "unitprice": 60,
|
||||
"discountamount": 0, "gstrate": 0.08,
|
||||
"taxamount": 4.44, "linetotal": 60
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Returns **404** if the reference does not belong to that `locationid` — even
|
||||
when the reference is a real bill at another outlet.
|
||||
|
||||
### `GET /sales/summary` — totals
|
||||
|
||||
```bash
|
||||
curl "$BASE/sales/summary?locationid=1135&fromdate=2026-08-01&todate=2026-08-03"
|
||||
```
|
||||
|
||||
Takes the same filters as `/sales`.
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "status": true,
|
||||
"details": {
|
||||
"locationid": 1135,
|
||||
"fromdate": "2026-08-01", "todate": "2026-08-03",
|
||||
"billcount": 137, "itemcount": 402,
|
||||
"grosssales": 18450.50, "taxcollected": 1204.30,
|
||||
"discountgiven": 320.00, "roundoff": -1.50,
|
||||
"averagebill": 134.68,
|
||||
"bypaymentmode": [
|
||||
{"paymentmode": "cash", "billcount": 80, "amount": 9200.00},
|
||||
{"paymentmode": "upi", "billcount": 57, "amount": 9250.50}
|
||||
],
|
||||
"byday": [
|
||||
{"businessdate": "2026-08-01", "billcount": 44, "amount": 5900.00}
|
||||
],
|
||||
"byterminal": [
|
||||
{"terminalid": "T4A9", "billcount": 137, "amount": 18450.50}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Three breakdowns because they answer three different questions: **by tender**
|
||||
for reconciling a drawer, **by day** for a chart, **by till** for an outlet
|
||||
running several counters.
|
||||
|
||||
### `GET /health/terminal` — one till
|
||||
|
||||
```bash
|
||||
curl "$BASE/health/terminal?terminal_id=T4A9"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "status": true,
|
||||
"details": {
|
||||
"terminal_id": "T4A9", "location_id": "1135",
|
||||
"store_name": "Ragul stores Selvapuram",
|
||||
"app_version": "1.1.0", "status": "online",
|
||||
"pending_bills": "0", "pending_registrations": "0",
|
||||
"oldest_pending_at": "",
|
||||
"today_bills": "2", "today_amount": "170",
|
||||
"last_bill_at": "",
|
||||
"printer_reachable": "0",
|
||||
"reported_at": "2026-08-03T12:04:21Z",
|
||||
"received_at": "2026-08-03T12:04:21Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Values are **strings** — it is a Redis hash. A till that has not reported inside
|
||||
its TTL returns `200` with `status: "offline"`, not a 404: it exists, it is
|
||||
simply quiet.
|
||||
|
||||
Fields the till does not collect are **absent, not zero**. A board showing every
|
||||
terminal at 0% battery is worse than one showing nothing.
|
||||
|
||||
`pending_bills` is the number worth watching. A shop quietly accumulating
|
||||
unsynced takings looks completely normal from the floor.
|
||||
|
||||
### `GET /health/location` — the "which counters are dark" board
|
||||
|
||||
```bash
|
||||
curl "$BASE/health/location?location_id=1135"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, "status": true,
|
||||
"details": {
|
||||
"location_id": "1135", "total": 3, "online": 2,
|
||||
"terminals": [
|
||||
{"terminal_id": "T4A9", "status": "online", "today_bills": "37", "…": "…"},
|
||||
{"terminal_id": "T7B2", "status": "offline", "reason": "no heartbeat within 90s"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A till whose key expired comes back marked **offline rather than 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.
|
||||
|
||||
### `GET /catalogue` — the till's product pull
|
||||
|
||||
Bare body, no envelope. Used by terminals, not the web app.
|
||||
|
||||
```bash
|
||||
curl "$BASE/catalogue?store_id=1135&page_size=500"
|
||||
curl "$BASE/catalogue?store_id=1135&since=loc1135-20260803T135407Z"
|
||||
```
|
||||
|
||||
No `since` → **full snapshot**. With a valid `since` → **change set**.
|
||||
|
||||
```json
|
||||
{
|
||||
"revision": "loc1135-20260803T135407Z",
|
||||
"is_delta": false,
|
||||
"has_more": false,
|
||||
"products": [{
|
||||
"id": "6988", "name": "Mysore Banana",
|
||||
"barcode": "6988", "sku": "",
|
||||
"category": "grocery", "price": 60, "stock": 750,
|
||||
"unit": "kilogram", "gst_rate": 0.08, "is_active": true
|
||||
}],
|
||||
"customers": [],
|
||||
"retired_product_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
**`is_delta` is the dangerous field.** `false` means the terminal withdraws
|
||||
every product the response does not mention. A filtered result labelled `false`
|
||||
empties the shelf. In `Catalogue()` the filter and the flag are derived from one
|
||||
value, so no code path can set one without the other.
|
||||
|
||||
Anything ambiguous resolves toward the snapshot: a revision that is malformed,
|
||||
empty, or issued to a different outlet yields a full response.
|
||||
|
||||
The revision **only advances on the final page**, so a terminal that abandons a
|
||||
paginated pull cannot end up holding one claiming it saw pages it never got.
|
||||
|
||||
A delta **cannot withdraw a deleted product** — removing a row from
|
||||
`productlocations` leaves no tombstone. Only a snapshot collects those, so tills
|
||||
should pull without a revision periodically.
|
||||
|
||||
---
|
||||
|
||||
## 5. MQTT topics
|
||||
|
||||
| Topic | Direction | Retained |
|
||||
|---|---|---|
|
||||
| `nearle/pos/{loc}/{terminal}/order` | till → us | no |
|
||||
| `nearle/pos/{loc}/{terminal}/customer` | till → us | no |
|
||||
| `nearle/pos/{loc}/{terminal}/health` | till → us, 30s | no |
|
||||
| `nearle/pos/{loc}/{terminal}/ack` | us → till | no |
|
||||
| `nearle/pos/{loc}/{terminal}/status` | till → us | **yes** (Last Will) |
|
||||
| `nearle/pos/{loc}/catalogue` | us → all tills at a shop | **yes** |
|
||||
|
||||
`{loc}` is the numeric `tenantlocations.locationid`. The tenant is resolved from
|
||||
it server-side and never taken from the wire.
|
||||
|
||||
Namespaced under `nearle/` alongside the rider fleet's `nearle/riders/…`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Database
|
||||
|
||||
**`pos_orders`** — one row per counter bill. Separate from `orders` because a
|
||||
bill carries a cashier, terminal, rounding, promos, loyalty and a payment split
|
||||
that `orders` has nowhere to put.
|
||||
|
||||
**`pos_order_items`** — one row per line.
|
||||
|
||||
**`productstocks`** — stock is **not** separate. A counter sale writes the same
|
||||
`out` rows an app order does, through helpers in `repositories/stockLedger.go`.
|
||||
Two stock ledgers would mean the catalogue pull sends a till figures that ignore
|
||||
its own trading.
|
||||
|
||||
**`customers`** — registrations, matched on `contactno`.
|
||||
|
||||
**Redis** — `pos:terminal:{code}` (hash, 90s TTL) and
|
||||
`pos:location:{id}:terminals` (set, no TTL). Namespaced `pos:*` so they cannot
|
||||
collide with express's `delivery:*`, `city:*`, `rider_*`.
|
||||
|
||||
> **Reporting:** counter sales are unioned into `GetRevenueSummary` and
|
||||
> `GetSalesSummary`. **Any new report must do the same**, or it will silently
|
||||
> understate every shop that runs a till. That is the standing cost of the split.
|
||||
|
||||
---
|
||||
|
||||
## 7. Code map
|
||||
|
||||
| File | |
|
||||
|---|---|
|
||||
| `models/pos.go` | wire types — matches the till's JSON exactly |
|
||||
| `models/posorder.go` | `pos_orders` / `pos_order_items` |
|
||||
| `models/poshealth.go` | heartbeat |
|
||||
| `repositories/posRepository.go` | ingest + catalogue |
|
||||
| `repositories/posSalesRepository.go` | the GET reads |
|
||||
| `repositories/posPresence.go` | Redis presence |
|
||||
| `repositories/stockLedger.go` | **shared** stock helpers |
|
||||
| `messaging/posmqtt.go` | MQTT consumer |
|
||||
| `messaging/posworkers.go` | bounded worker pools |
|
||||
| `controllers/posController.go` | HTTP handlers |
|
||||
| `routes/posroutes.go` | routes |
|
||||
|
||||
---
|
||||
|
||||
## 8. Operational notes
|
||||
|
||||
**Only `fiesta-0` consumes.** MQTT has no queue groups, so all replicas would
|
||||
receive every message and commit the same bill three times. Ordinal 0 is
|
||||
elected; the others log *"not the elected consumer"*. Override with
|
||||
`POS_MQTT_CONSUMER=always|never`.
|
||||
|
||||
**Worker pools**: `POS_INGEST_WORKERS` (default 8), `POS_HEALTH_WORKERS`
|
||||
(default 2). Heartbeats have their own pool so a backlog of bills cannot make
|
||||
every till look dark at the busiest moment. A full queue **blocks**, pushing
|
||||
backpressure to the broker and the till — slow, never lossy.
|
||||
|
||||
**Startup check**: three `pos: subscribed to nearle/pos/+/+/…` lines on
|
||||
`fiesta-0`. Without them, MQTT ingest is not running and tills queue silently.
|
||||
|
||||
**The broker is not durable storage.** Mosquitto's `max_queued_messages` is 1000
|
||||
and it flushes every 30 minutes. Fine, because a till keeps its copy until we
|
||||
acknowledge — but nobody may ever ack on the broker's behalf.
|
||||
|
||||
---
|
||||
|
||||
## 9. Known gaps
|
||||
|
||||
- **No product prices.** Every product at loc 1135 is ₹0, so nothing is
|
||||
sellable. Unpriced products come down as `is_active: false` so a till cannot
|
||||
ring up a ₹0 item.
|
||||
- **The Flutter app has never been run.** All testing used a Go program
|
||||
impersonating a till.
|
||||
- **No TLS** on port 1883. Bills carry customer names and mobile numbers.
|
||||
- **No device authentication.** A terminal is trusted with a location id.
|
||||
- **Loyalty does not come back down.** Balances at a till are that till's view.
|
||||
- **`productstocks.quantity` is an integer** but tills sell in kg. POS rounds
|
||||
**up** so it never under-deducts; the app-order path truncates, which was left
|
||||
alone rather than silently changed. Making the column numeric is the real fix.
|
||||
- **Broker credentials in source** — `admin` is in the rider APK and still
|
||||
unrestricted. The two POS accounts are the only ones not in a source tree.
|
||||
@@ -52,11 +52,41 @@ the standing cost of the split.
|
||||
|
||||
### HTTP
|
||||
|
||||
Base path: `/live/api/v1/pos`
|
||||
|
||||
**Written by a terminal** — bare-ack responses, see below.
|
||||
|
||||
| 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` |
|
||||
| `POST` | `/orders` | Completed bills |
|
||||
| `POST` | `/customers` | Shoppers registered at a till |
|
||||
| `GET` | `/catalogue` | Product pull. Query: `store_id`, `since`, `page`, `page_size` |
|
||||
|
||||
**Read by the web app** — normal `{code, message, status, details}` envelope.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/sales` | Bills for an outlet, newest first |
|
||||
| `GET` | `/sales/detail` | One bill with its lines |
|
||||
| `GET` | `/sales/summary` | Totals by tender, day and till |
|
||||
| `GET` | `/health/terminal` | One till's live state |
|
||||
| `GET` | `/health/location` | Every till at a shop |
|
||||
|
||||
`/sales` and `/sales/summary` take: **`locationid` (required)**, `fromdate`,
|
||||
`todate` (YYYY-MM-DD, matched on `businessdate`), `terminalid`, `cashiername`,
|
||||
`paymentmode`, `pageno`, `pagesize`.
|
||||
|
||||
`/sales/detail` takes `locationid` and `reference` — the terminal's order UUID,
|
||||
the invoice number, or the `posorderid`, whichever the caller happens to have.
|
||||
|
||||
**`locationid` is the authorisation boundary.** Every read is scoped to one
|
||||
outlet; omitting it is an error rather than a page through every shop's takings,
|
||||
and asking for a bill under the wrong outlet returns 404 even when the reference
|
||||
is valid.
|
||||
|
||||
Dates match `businessdate` — the day the sale was rung, not the day it reached
|
||||
us. A till that was offline overnight uploads yesterday's bills this morning and
|
||||
they belong to yesterday.
|
||||
|
||||
These answer with a **bare ack**, not the usual `{code, message, status}`
|
||||
envelope — the terminal reads `accepted` from the top level of the body:
|
||||
@@ -162,13 +192,45 @@ Worth knowing before the first bill lands.
|
||||
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.
|
||||
- **Catalogue** answers a snapshot or a change set, decided by the `since`
|
||||
revision — see below.
|
||||
- **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.
|
||||
|
||||
## Catalogue: snapshots and deltas
|
||||
|
||||
`GET /catalogue?store_id=1135` with no `since` returns a **full snapshot**. The
|
||||
response carries a `revision`; the terminal stores it and sends it back next
|
||||
time as `since=`, and then gets only what changed.
|
||||
|
||||
A product is included in a change set when any of three things moved: the
|
||||
product row (name, tax, brand), its row at this outlet (price, availability), or
|
||||
its stock ledger. Stock counts because a shop's figure drifts from a till's on
|
||||
every sale rung at another counter, and a delta that ignored it would let that
|
||||
drift persist until someone forced a full pull.
|
||||
|
||||
**The one rule that matters.** A response marked `is_delta: false` is treated as
|
||||
a snapshot, and the terminal **withdraws every product it does not mention**. A
|
||||
filtered result labelled `false` therefore empties the shop's shelf. The filter
|
||||
and the flag are computed from a single value in `Catalogue()` — there is no
|
||||
path that filters without also setting the flag, and that is deliberate.
|
||||
|
||||
**A revision that cannot be read falls back to a full snapshot.** Malformed,
|
||||
empty, or issued to a different outlet — all yield a zero cutoff and a complete
|
||||
response. The other direction would leave a terminal permanently missing every
|
||||
change it had not already seen, with nothing to indicate it.
|
||||
|
||||
**The revision only advances on the final page.** A terminal that abandons a
|
||||
paginated pull half way gets back the revision it already had — or an empty one,
|
||||
meaning the next pull is a snapshot. Both are recoverable; a prematurely
|
||||
advanced revision is not.
|
||||
|
||||
**A delta cannot withdraw a deleted product.** A row removed from
|
||||
`productlocations` leaves no tombstone, so nothing tells the change set to
|
||||
retire it. Only a snapshot collects those, which is why a terminal should pull
|
||||
without a revision periodically — the morning import is the natural moment.
|
||||
|
||||
## Terminal health
|
||||
|
||||
Every till publishes a heartbeat to `nearle/pos/{loc}/{terminal}/health` every
|
||||
@@ -225,8 +287,6 @@ 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
|
||||
@@ -239,75 +299,56 @@ state.
|
||||
as zero: a board showing every till at 0% battery is worse than one showing
|
||||
nothing.
|
||||
|
||||
## Broker hardening — before a hundred tills join
|
||||
## Broker accounts
|
||||
|
||||
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.
|
||||
Applied 2026-08-03 on `66.116.225.226`. Two scoped accounts now exist alongside
|
||||
`admin`, with an ACL at `/mosquitto/config/acl` referenced from
|
||||
`mosquitto.conf`.
|
||||
|
||||
**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.
|
||||
| User | May publish | May subscribe |
|
||||
|---|---|---|
|
||||
| `pos_terminal` | `nearle/pos/+/+/{order,customer,status,health}` | `nearle/pos/+/+/{ack,command}`, `nearle/pos/+/catalogue` |
|
||||
| `pos_ingest` | `nearle/pos/+/+/{ack,command}`, `nearle/pos/+/catalogue` | `nearle/pos/+/+/{order,customer,health,status}` |
|
||||
| `admin` | everything — **deliberately unchanged** | everything |
|
||||
|
||||
A scoped account is two commands and a container restart:
|
||||
A till therefore cannot publish to `nearle/riders/#` or `doormile/#`, and cannot
|
||||
write its own ack topic — only the ingest may do that. Verified by publishing as
|
||||
`pos_terminal` to all four and watching which arrived: the order did, the other
|
||||
three did not.
|
||||
|
||||
```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>'
|
||||
```
|
||||
**`admin` was left unrestricted on purpose.** Its credentials are compiled into
|
||||
the rider app, so narrowing it here would cut off the live rider fleet without
|
||||
warning. The right next step is:
|
||||
|
||||
```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.
|
||||
but only once someone has confirmed nothing else authenticates as `admin`.
|
||||
Until then the ACL changes nothing for it — which is why applying it was safe.
|
||||
|
||||
**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.
|
||||
Rollback, if ever needed:
|
||||
|
||||
**Two more, from the audit:**
|
||||
```bash
|
||||
cp /root/Mqtt/backup-<timestamp>/{mosquitto.conf,passwd} /root/Mqtt/config/
|
||||
docker restart mqtt_broker
|
||||
```
|
||||
|
||||
- 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.
|
||||
**Still outstanding on the broker:**
|
||||
|
||||
- **No TLS.** Port 8883 is not configured. Bills carry customer names and mobile
|
||||
numbers, and they travel in the clear. Traefik on the same host already
|
||||
terminates 443, so certificates exist to borrow from.
|
||||
- **`passwd` is world-readable.** Mosquitto warns about it and future versions
|
||||
will refuse to load it. Tightening it means `chown 1883:1883` as well as
|
||||
`chmod`, because the broker runs as uid 1883 and a root-owned 0600 file would
|
||||
stop it starting.
|
||||
- **Credentials in source.** `admin` is in the rider APK, Redis is hardcoded in
|
||||
the express backend, and Postgres was in this repository's git history until
|
||||
2026-08-03. The POS accounts above are the only ones not in any source tree —
|
||||
keep it that way.
|
||||
|
||||
## Capacity
|
||||
|
||||
|
||||
95
SECURITY_HANDOFF.md
Normal file
95
SECURITY_HANDOFF.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Handoff: Broken Access Control (IDOR) audit & fixes — Fiesta backend
|
||||
|
||||
Repo: `backend_fiesta` (Go + Fiber + GORM), consumed by `nearledaily/daily_merchant_web` (React/TS) and a mobile app (not in this repo).
|
||||
|
||||
## 1. The root problem (still not fully fixed — read this first)
|
||||
|
||||
**There is no authentication system in this backend.** Grep confirms:
|
||||
- No JWT/session token is ever issued. `Login`, `TenantLogin`, `TenantWebLogin`, `AppLogin` (in `controllers/userController.go`) just look up the user/tenant and return their info in the JSON body — no token.
|
||||
- No auth middleware exists anywhere. `routes/routes.go` / `main.go` only wire up CORS middleware. Every route is wide open — anyone who can reach the API can call any endpoint with any query params.
|
||||
|
||||
Because of that, every endpoint trusts client-supplied query params (`tenantid`, `customerid`, `partnerid`, etc.) as the sole source of "who is asking." There is currently **nothing stopping a logged-in store admin for tenant 1135 from just requesting `?tenantid=1136`** and getting another tenant's data — the frontend happens to always send the logged-in user's own tenantid, but the backend never checks it.
|
||||
|
||||
**This session's fixes only close one specific hole**, described below. The real fix — deriving identity server-side from a verified token instead of trusting query params — has not been started. Whoever picks this up should treat that as the actual next milestone.
|
||||
|
||||
## 2. The specific bug that was found and fixed this session
|
||||
|
||||
Pattern found repeatedly across the codebase: repository functions build SQL dynamically, e.g.
|
||||
|
||||
```go
|
||||
query := "SELECT ... FROM orders WHERE 1=1"
|
||||
if tenantID != 0 {
|
||||
query += " AND tenantid = ?"
|
||||
params = append(params, tenantID)
|
||||
}
|
||||
// ...similar optional blocks for partnerid, customerid, etc.
|
||||
```
|
||||
|
||||
**If none of the scoping params were supplied (0 / empty), the query silently fell through to "no WHERE clause" and returned every row in the table across every tenant.** This was directly reachable — e.g. `orders/getorders` with no `tenantid` returned all ~300 orders in the DB rather than 400ing, which is how the user first noticed this (logged in as a store admin, expected only their store's orders, saw everyone's).
|
||||
|
||||
### Fix pattern applied
|
||||
|
||||
Rather than rewriting every repository query (large surface area, higher regression risk), a **controller-level guard** was added to each affected endpoint: if none of the valid scoping ids are present in the query string, return `400` immediately instead of calling the service/repo at all.
|
||||
|
||||
Standard error shape used everywhere:
|
||||
```json
|
||||
{ "status": false, "code": 400, "message": "<specific message>" }
|
||||
```
|
||||
|
||||
## 3. Endpoints fixed (8 total)
|
||||
|
||||
| # | Endpoint | File / function | Guard added |
|
||||
|---|---|---|---|
|
||||
| 1 | `GET /v1/web/orders/getorders` (+ mob) | `controllers/orderController.go` `GetOrders` (line 24) | requires one of `tenantid`, `partnerid`, `customerid`, `applocationid`, `appuserid` — else 400 (line ~102-110). Previously the `else` branch called `GetAllOrders` (unscoped). |
|
||||
| 2 | `GET /v1/web/orders/getordersummary` | `controllers/orderController.go` `GetOrderSummary` (line 129) | requires one of `tenantid`, `partnerid`, `customerid`, `locationid` (line 137-143) |
|
||||
| 3 | `GET /v1/web/orders/getlocationsummary` | `controllers/orderController.go` `GetlocationOrderSummary` (line 163) | requires `tenantid` (line 167-173) |
|
||||
| 4 | `GET /v1/web/users/getallusers` | `controllers/userController.go` `GetAllUsers` (line 22) | requires `tenantid` (line 29-35). Note: this endpoint's query selects `a.pin` (login PIN) — this was a high-severity leak (PINs across all tenants) before the fix. |
|
||||
| 5 | `GET /v1/web/deliveries/getdeliveries` (+ mob) | `controllers/deliveriesController.go` `GetDeliveries` (line 194) | requires one of `tenantid`, `partnerid`, `customerid`, `applocationid`, `userid`, `appuserid` (line 212-218) |
|
||||
| 6 | `GET /v1/web/partners/getriders` (+ mob) | `controllers/partnerController.go` `GetActiveRiders` (line 19) | requires one of `tenantid`, `partnerid`, `applocationid`, `userid` (line 25-31). Lower severity — underlying repo query defaults to `userid = 0` rather than a full dump, but fixed for consistency. |
|
||||
| 7 | `GET /v1/web/partners/getriderlogs` (+ mob) | `controllers/partnerController.go` `GetRiderLogs` (line 121) | requires one of `partnerid`, `applocationid` (line 127-133). **Also fixed an unrelated bug in the same function**: `tdate` was reading `c.Query("fromdate")` (copy-paste error) so the end of any date range was always silently overwritten with the start date. Now correctly reads `c.Query("todate")` (line 125). |
|
||||
| 8 | `POST /v1/mob/orders/getcustomerorders` | `controllers/orderController.go` `GetCustomerOrders` (line 374) | requires `customerid` (line 394-400) |
|
||||
|
||||
### Also fixed alongside #2: SQL injection in `GetOrderSummary`
|
||||
|
||||
`repositories/orderRepository.go` `GetOrderSummary` previously built the date filter by **string-concatenating** `fdate`/`tdate` directly into raw SQL. Rewritten to use parameterized `?` placeholders passed through `r.db.Raw(query, params...)`. The `strconv` import was removed from that file since it became unused after the rewrite (verified via grep no other usage remained).
|
||||
|
||||
## 4. Reviewed and explicitly NOT changed (don't re-flag these)
|
||||
|
||||
Same `WHERE 1=1` pattern exists elsewhere but was judged not to be a bug, or already safe:
|
||||
|
||||
- **`repositories/tenantRepository.go` `GetAllTenants`** — intentionally lists all tenants for a platform/super-admin console. The gap here is "no RBAC to restrict who can call this," which is the same root-cause auth gap from section 1, not a scoping bug to patch individually.
|
||||
- **`repositories/productRepository.go` `GetProductSubCategory`** — has an explanatory comment: subcategories are intentionally shared/global master data plus tenant-owned overrides. Not a bug.
|
||||
- **`repositories/productRepository.go` `GetProductCount`** — returns aggregate counts only (no PII), low severity, left as-is.
|
||||
- **`repositories/utilsRepository.go` `GetSubcategories`** — global taxonomy/reference data; the model has no tenant field at all.
|
||||
- **`repositories/orderRepository.go` `GetAdminOrders`** — has `WHERE 1=1` internally but is safe because its only caller (`GetOrders` controller) only invokes it when `applocationid != 0`.
|
||||
- **`repositories/orderRepository.go` `GetAllOrders`** — now dead code (unreachable) after fix #1 above; confirmed via grep it's no longer called anywhere. Could be deleted as cleanup but left in place.
|
||||
- **`repositories/tenantRepository.go` `GetTenantLocations`** — already always filters `WHERE tenantid = ?`. Safe, unchanged.
|
||||
|
||||
## 5. Known pre-existing bug found during this audit, NOT yet fixed anywhere
|
||||
|
||||
**Frontend/backend path mismatch on rider logs.** In `nearledaily/daily_merchant_web/src/services/fiestaApi.ts`, `getRiderLogs()` (~line 1132) calls:
|
||||
```ts
|
||||
fiestaGet('riders/getriderlogs', {...})
|
||||
```
|
||||
`FIESTA_BASE` is `https://fiesta.nearle.app/live/api/v1/web`, so this resolves to `.../v1/web/riders/getriderlogs`. But the backend only registers this route under the `partners` group (`routes/partnerroutes.go`): `partner.Get("/getriderlogs", ...)` on `api.Group("/v1/web/partners")`, i.e. the real path is `.../v1/web/partners/getriderlogs`. Confirmed via grep there is no `/v1/web/riders` route group anywhere in the backend.
|
||||
|
||||
**This means `getRiderLogs()` in the web console has likely been 404ing already, independent of anything fixed this session.** Fix is a one-line FE change: `'riders/getriderlogs'` → `'partners/getriderlogs'`. Not fixed yet because it's a frontend-repo change and wasn't the scope of this backend security pass — flagging it here so it isn't lost.
|
||||
|
||||
## 6. Frontend compatibility check (already done, no FE changes needed for the 8 fixes above)
|
||||
|
||||
Checked `daily_merchant_web/src/services/fiestaApi.ts` against every fix — the web console already sends the now-required params in all cases:
|
||||
- `getOrders`, `getAllUsers`, `getDeliveries`, `getOrderSummary`, `getLocationSummary` — all declare `tenantid: number` as a **required** (non-optional) TS field already.
|
||||
- `getRiders` — always sends `applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID` (never zero/undefined) plus required `tenantid`.
|
||||
- `getRiderLogs` — sends `tenantid`/`applocationid` when available, but see the path bug in section 5 — worth re-verifying once that's fixed.
|
||||
- **`mob/orders/getcustomerorders`** (fix #8) is called from the **mobile app**, which is not in this repo — whoever owns that codebase needs to verify every call site always sends `customerid`. Not verified in this session.
|
||||
|
||||
## 7. Environment note
|
||||
|
||||
**No Go toolchain is available in the sandbox this session ran in** (`command not found: go`). All edits above were manually reviewed (imports, syntax, call sites checked via Read/grep) but **never compiled**. Run `go build ./...` and the existing test suite (if any) before deploying any of this.
|
||||
|
||||
## 8. Suggested next steps for whoever picks this up
|
||||
|
||||
1. `go build ./...` and smoke-test all 8 changed endpoints (call with and without the required param, confirm 200 vs 400).
|
||||
2. Fix the `riders/getriderlogs` → `partners/getriderlogs` path bug in `fiestaApi.ts` (section 5).
|
||||
3. Verify the mobile app always sends `customerid` to `mob/orders/getcustomerorders` before this ships, since that's the one fixed endpoint not verified from a frontend contract.
|
||||
4. Scope and plan the real fix: JWT/session auth issuance + middleware, so `tenantid`/`customerid`/etc. are derived from a verified server-side identity instead of trusted from query params. Until that lands, the 8 fixes in this doc only prevent the "forgot to pass an id → get everything" failure mode — they do **not** prevent a malicious or buggy client from passing a *different* tenant's/customer's/partner's real id and getting their data.
|
||||
@@ -1,6 +1,7 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -94,6 +95,63 @@ func (ctl *PosController) IngestCustomers(c *fiber.Ctx) error {
|
||||
return c.Status(http.StatusOK).JSON(ack)
|
||||
}
|
||||
|
||||
// IngestHealth records one heartbeat from a till.
|
||||
//
|
||||
// The same heartbeat the broker carries, over HTTP, because presence was
|
||||
// previously reachable *only* over MQTT — a terminal configured for the HTTP
|
||||
// route reported bills perfectly and never appeared on the fleet board at all,
|
||||
// with nothing anywhere to say why. A monitoring feature that silently does not
|
||||
// exist on one of two supported transports is worse than no feature.
|
||||
//
|
||||
// Answers 202 rather than 200: nothing is committed, and the till is told not
|
||||
// to wait on it. Failures are swallowed for the same reason the MQTT path
|
||||
// swallows them — a terminal that cannot say how it is must still sell, and a
|
||||
// blank square on a dashboard beats a till that stopped because Redis was busy.
|
||||
func (ctl *PosController) IngestHealth(c *fiber.Ctx) error {
|
||||
var health models.PosHealth
|
||||
|
||||
if err := c.BodyParser(&health); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "could not read the heartbeat: " + err.Error(),
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
// Over MQTT these come from the topic. There is no topic here, so the body
|
||||
// is the only source and both are required — a heartbeat that cannot say
|
||||
// which till it belongs to is unfilable.
|
||||
if strings.TrimSpace(health.Terminalid) == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false,
|
||||
"message": "terminal_id is required",
|
||||
})
|
||||
}
|
||||
if strings.TrimSpace(health.Locationid) == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false,
|
||||
"message": "location_id is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Matches the consumer: a bare {"status":"offline"} is a Last Will and must
|
||||
// survive as-is, but an unset status from a till that is plainly talking to
|
||||
// us means online.
|
||||
if strings.TrimSpace(health.Status) == "" {
|
||||
health.Status = "online"
|
||||
}
|
||||
|
||||
if err := ctl.posService.RecordHealth(c.Context(), health); err != nil {
|
||||
// Logged, not returned. See above — the till must not slow down for it.
|
||||
log.Printf("pos: could not record heartbeat from %s/%s over HTTP: %v",
|
||||
health.Locationid, health.Terminalid, err)
|
||||
}
|
||||
|
||||
return c.Status(http.StatusAccepted).JSON(fiber.Map{
|
||||
"status": true, "code": http.StatusAccepted,
|
||||
})
|
||||
}
|
||||
|
||||
// Catalogue answers a terminal's product pull.
|
||||
func (ctl *PosController) Catalogue(c *fiber.Ctx) error {
|
||||
storeID := strings.TrimSpace(c.Query("store_id"))
|
||||
@@ -188,6 +246,107 @@ func (ctl *PosController) LocationHealth(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Sales reads
|
||||
//
|
||||
// Unlike the ingest handlers above, these answer in the usual
|
||||
// `{code, message, status, details}` envelope — they are read by the web app,
|
||||
// not by a terminal, and nothing about them is bound to the till's contract.
|
||||
|
||||
// posSalesFilter reads the shared query parameters.
|
||||
func posSalesFilter(c *fiber.Ctx) (models.PosSalesFilter, error) {
|
||||
locationID, err := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
|
||||
if err != nil || locationID <= 0 {
|
||||
return models.PosSalesFilter{}, fmt.Errorf("locationid is required")
|
||||
}
|
||||
|
||||
pageno, _ := strconv.Atoi(c.Query("pageno", "0"))
|
||||
pagesize, _ := strconv.Atoi(c.Query("pagesize", "50"))
|
||||
|
||||
return models.PosSalesFilter{
|
||||
Locationid: locationID,
|
||||
Fromdate: strings.TrimSpace(c.Query("fromdate")),
|
||||
Todate: strings.TrimSpace(c.Query("todate")),
|
||||
Terminalid: strings.TrimSpace(c.Query("terminalid")),
|
||||
Cashiername: strings.TrimSpace(c.Query("cashiername")),
|
||||
Paymentmode: strings.TrimSpace(c.Query("paymentmode")),
|
||||
Pageno: pageno,
|
||||
Pagesize: pagesize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetSales lists counter bills for an outlet, newest first.
|
||||
func (ctl *PosController) GetSales(c *fiber.Ctx) error {
|
||||
filter, err := posSalesFilter(c)
|
||||
if err != nil {
|
||||
return posBadRequest(c, err)
|
||||
}
|
||||
|
||||
page, err := ctl.posService.Sales(filter)
|
||||
if err != nil {
|
||||
return posServerError(c, "GetSales", err)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": page})
|
||||
}
|
||||
|
||||
// GetSaleDetail returns one bill with its lines.
|
||||
//
|
||||
// Accepts the terminal's order UUID, the invoice number, or this backend's
|
||||
// posorderid — a support call starts from whichever the caller is looking at.
|
||||
func (ctl *PosController) GetSaleDetail(c *fiber.Ctx) error {
|
||||
locationID, err := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
|
||||
if err != nil || locationID <= 0 {
|
||||
return posBadRequest(c, fmt.Errorf("locationid is required"))
|
||||
}
|
||||
|
||||
reference := strings.TrimSpace(c.Query("reference"))
|
||||
if reference == "" {
|
||||
return posBadRequest(c, fmt.Errorf("reference is required — an order id, invoice number or posorderid"))
|
||||
}
|
||||
|
||||
bill, err := ctl.posService.SaleDetail(locationID, reference)
|
||||
if err != nil {
|
||||
return posServerError(c, "GetSaleDetail", err)
|
||||
}
|
||||
if bill == nil {
|
||||
return c.Status(http.StatusNotFound).JSON(fiber.Map{
|
||||
"code": http.StatusNotFound,
|
||||
"message": "no bill matches that reference at this outlet",
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": bill})
|
||||
}
|
||||
|
||||
// GetSalesSummary totals a range, split by tender, day and till.
|
||||
func (ctl *PosController) GetSalesSummary(c *fiber.Ctx) error {
|
||||
filter, err := posSalesFilter(c)
|
||||
if err != nil {
|
||||
return posBadRequest(c, err)
|
||||
}
|
||||
|
||||
summary, err := ctl.posService.SalesSummary(filter)
|
||||
if err != nil {
|
||||
return posServerError(c, "GetSalesSummary", err)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": summary})
|
||||
}
|
||||
|
||||
func posBadRequest(c *fiber.Ctx, err error) error {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "message": err.Error(), "status": false,
|
||||
})
|
||||
}
|
||||
|
||||
func posServerError(c *fiber.Ctx, op string, err error) error {
|
||||
log.Printf("pos %s: %v", op, err)
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"code": http.StatusInternalServerError, "message": err.Error(), "status": false,
|
||||
})
|
||||
}
|
||||
|
||||
// posIngestError decides whether the terminal should retry.
|
||||
//
|
||||
// The distinction matters more than the message does. A misconfigured store id
|
||||
|
||||
@@ -16,22 +16,17 @@ import (
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
// Plain-MQTT ingest for the Nearle POS terminals.
|
||||
// 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:
|
||||
// The broker is Eclipse Mosquitto, shared with the rider fleet. An audit of the
|
||||
// estate found no reachable NATS and no MQTT gateway on the NATS boxes that do
|
||||
// exist, so a NATS consumer that briefly lived here was deleted rather than
|
||||
// left to rot — a client for a protocol nothing speaks is worse than none.
|
||||
//
|
||||
// - **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.
|
||||
// Enabled with MQTT_URL. Unset, the terminals reach the same service over HTTP
|
||||
// instead, and this file does nothing.
|
||||
//
|
||||
// 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.
|
||||
// Only one replica consumes: see posConsumerElected.
|
||||
|
||||
const (
|
||||
// Namespaced under `nearle/` alongside the rider app's
|
||||
@@ -47,6 +42,12 @@ const (
|
||||
type PosMqttConsumer struct {
|
||||
client mqtt.Client
|
||||
svc services.PosService
|
||||
|
||||
// Bills and registrations share a pool; heartbeats get their own, so a
|
||||
// backlog of sales cannot make every till look dark at the moment the
|
||||
// system is busiest.
|
||||
ingest *posPool
|
||||
health *posPool
|
||||
}
|
||||
|
||||
// StartPosMqttConsumer connects and subscribes.
|
||||
@@ -60,14 +61,47 @@ func StartPosMqttConsumer(svc services.PosService) (*PosMqttConsumer, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
c := &PosMqttConsumer{svc: svc}
|
||||
// Only one replica consumes.
|
||||
//
|
||||
// MQTT has no queue groups — every subscriber receives every message, so
|
||||
// three replicas would each commit the same bill and publish three acks.
|
||||
// The ingest is idempotent, so nothing double-counts, but it is three times
|
||||
// the database work and three times the traffic for one sale.
|
||||
//
|
||||
// A StatefulSet gives pods stable ordinal names, so ordinal 0 is a
|
||||
// deterministic election with no coordination and no extra dependency. If
|
||||
// that pod dies the set recreates it; tills hold their bills and re-send in
|
||||
// the meantime, which is exactly what they are built to do.
|
||||
if !posConsumerElected() {
|
||||
log.Printf("pos: replica %q is not the elected consumer, MQTT ingest idle here",
|
||||
os.Getenv("HOSTNAME"))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Each ingest worker holds a database transaction while it runs, so the
|
||||
// real ceiling is the Postgres connection pool rather than the CPU. The
|
||||
// queue is deep enough to absorb a burst and shallow enough that a genuine
|
||||
// overload is felt as backpressure rather than hidden as latency.
|
||||
c := &PosMqttConsumer{
|
||||
svc: svc,
|
||||
ingest: newPosPool("ingest", posPoolSize("POS_INGEST_WORKERS", 8), 256),
|
||||
health: newPosPool("health", posPoolSize("POS_HEALTH_WORKERS", 2), 512),
|
||||
}
|
||||
|
||||
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")).
|
||||
// Defaults to the pod name so replicas can never collide: a second
|
||||
// connection with the same client id evicts the first, and the two then
|
||||
// fight in a reconnect loop that looks like a flapping network.
|
||||
SetClientID(getEnvDefault("MQTT_CLIENT_ID",
|
||||
getEnvDefault("HOSTNAME", "nearle-pos-ingest"))).
|
||||
SetCleanSession(false).
|
||||
// Ordered delivery keeps paho on one goroutine, which is what lets a
|
||||
// full queue push back on the broker. With concurrent delivery paho
|
||||
// would keep reading no matter how far behind the workers were.
|
||||
SetOrderMatters(posOrderedDelivery).
|
||||
SetAutoReconnect(true).
|
||||
SetMaxReconnectInterval(30 * time.Second).
|
||||
SetKeepAlive(30 * time.Second).
|
||||
@@ -85,9 +119,9 @@ func StartPosMqttConsumer(svc services.PosService) (*PosMqttConsumer, error) {
|
||||
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,
|
||||
topicOrders: wrapHandler(c.ingest, c.handleOrders),
|
||||
topicCustomers: wrapHandler(c.ingest, c.handleCustomers),
|
||||
topicHealth: wrapHandler(c.health, 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())
|
||||
@@ -254,6 +288,12 @@ func (c *PosMqttConsumer) Close() {
|
||||
if c == nil || c.client == nil {
|
||||
return
|
||||
}
|
||||
// Workers drain before the connection closes, so a bill mid-commit still
|
||||
// gets its ack out. Disconnecting first would strand it: committed here,
|
||||
// unacknowledged there, and sent again on the till's next attempt.
|
||||
c.ingest.stop()
|
||||
c.health.stop()
|
||||
|
||||
quiesce, err := strconv.Atoi(getEnvDefault("MQTT_QUIESCE_MS", "2000"))
|
||||
if err != nil || quiesce < 0 {
|
||||
quiesce = 2000
|
||||
@@ -261,6 +301,37 @@ func (c *PosMqttConsumer) Close() {
|
||||
c.client.Disconnect(uint(quiesce))
|
||||
}
|
||||
|
||||
// posConsumerElected decides whether this replica runs the MQTT ingest.
|
||||
//
|
||||
// Rules, in order:
|
||||
//
|
||||
// - POS_MQTT_CONSUMER=always or =never settles it outright, for deployments
|
||||
// that are not a StatefulSet or that want the consumer somewhere specific.
|
||||
// - A StatefulSet pod name ending in `-0` is elected. Ordinals are stable and
|
||||
// unique, so this needs no lock, no lease and no coordination.
|
||||
// - Anything else — a bare container, a Deployment, local development —
|
||||
// is elected, because a single instance that refused to consume would be a
|
||||
// far more confusing failure than one that did.
|
||||
func posConsumerElected() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("POS_MQTT_CONSUMER"))) {
|
||||
case "always", "true", "yes":
|
||||
return true
|
||||
case "never", "false", "no":
|
||||
return false
|
||||
}
|
||||
|
||||
host := strings.TrimSpace(os.Getenv("HOSTNAME"))
|
||||
if i := strings.LastIndex(host, "-"); i >= 0 {
|
||||
if ordinal := host[i+1:]; ordinal != "" && strings.Trim(ordinal, "0123456789") == "" {
|
||||
// A StatefulSet ordinal. Only the first replica consumes.
|
||||
return ordinal == "0"
|
||||
}
|
||||
}
|
||||
|
||||
// Not an ordinal-named pod, so there is nothing to elect against.
|
||||
return true
|
||||
}
|
||||
|
||||
func getEnvDefault(key, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
|
||||
@@ -56,6 +56,18 @@ func (f *fakePosService) TerminalHealth(context.Context, string) (map[string]str
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePosService) Sales(models.PosSalesFilter) (*models.PosSalesPage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePosService) SaleDetail(int, string) (*models.PosOrders, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePosService) SalesSummary(models.PosSalesFilter) (*models.PosSalesSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePosService) LocationHealth(context.Context, string) ([]map[string]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -100,9 +112,9 @@ func (c *fakeClient) Subscribe(string, byte, mqtt.MessageHandler) mqtt.Token {
|
||||
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{} }
|
||||
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{}
|
||||
|
||||
@@ -368,3 +380,44 @@ func TestTopicIdentityRejectsShortTopics(t *testing.T) {
|
||||
t.Errorf("topicIdentity = %q/%q, want 12/T4A9", store, terminal)
|
||||
}
|
||||
}
|
||||
|
||||
// MQTT has no queue groups: every subscriber gets every message. Three replicas
|
||||
// all consuming would commit the same bill three times and publish three acks —
|
||||
// harmless, because the ingest is idempotent, but three times the work.
|
||||
func TestOnlyTheFirstStatefulSetReplicaConsumes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
hostname string
|
||||
override string
|
||||
want bool
|
||||
}{
|
||||
{"statefulset ordinal 0", "fiesta-0", "", true},
|
||||
{"statefulset ordinal 1", "fiesta-1", "", false},
|
||||
{"statefulset ordinal 2", "fiesta-2", "", false},
|
||||
{"double-digit ordinal", "fiesta-10", "", false},
|
||||
|
||||
// A Deployment pod has a random suffix, not an ordinal. Refusing to
|
||||
// consume there would be a far more confusing failure than consuming.
|
||||
{"deployment pod", "fiesta-7d4f9c8b6d-x2k9p", "", true},
|
||||
{"bare container", "a1b2c3d4e5f6", "", true},
|
||||
{"no hostname", "", "", true},
|
||||
|
||||
// The override settles it outright either way.
|
||||
{"forced on", "fiesta-2", "always", true},
|
||||
{"forced off", "fiesta-0", "never", false},
|
||||
{"forced on via true", "fiesta-5", "true", true},
|
||||
{"forced off via false", "fiesta-0", "false", false},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Setenv("HOSTNAME", c.hostname)
|
||||
t.Setenv("POS_MQTT_CONSUMER", c.override)
|
||||
|
||||
if got := posConsumerElected(); got != c.want {
|
||||
t.Errorf("posConsumerElected() = %v, want %v (hostname %q, override %q)",
|
||||
got, c.want, c.hostname, c.override)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
174
messaging/posworkers.go
Normal file
174
messaging/posworkers.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
// Concurrency for the MQTT ingest.
|
||||
//
|
||||
// paho delivers messages on a single goroutine, so without this every bill is
|
||||
// committed one after another. A bill is a full Postgres transaction — advisory
|
||||
// lock, dedup check, stock row locks, availability check, four inserts, commit —
|
||||
// which realistically costs 10–30ms. Serially that is 30–100 bills a second,
|
||||
// and a shop-wide backlog draining after an outage would take minutes to land.
|
||||
//
|
||||
// ### Why a bounded pool rather than a goroutine per message
|
||||
//
|
||||
// paho can be told to call handlers concurrently, but it spawns without limit.
|
||||
// A storm would then open a database transaction per message, exhaust the
|
||||
// connection pool, and stall every one of them at once — turning a slow minute
|
||||
// into a dead one.
|
||||
//
|
||||
// A fixed pool behind a bounded queue does the opposite. When the queue fills,
|
||||
// submitting **blocks**, which is the point: paho stops acknowledging, the
|
||||
// broker's in-flight window fills, and it stops sending. Backpressure travels
|
||||
// all the way back to the till, which holds its bills and retries. Slow, but
|
||||
// nothing is dropped and nothing is lost.
|
||||
//
|
||||
// ### Why bills and heartbeats have separate pools
|
||||
//
|
||||
// A heartbeat is one Redis write and a bill is a transaction. Sharing a queue
|
||||
// would let a backlog of bills delay presence, and every till would appear to
|
||||
// go dark at exactly the moment the system was busiest — the worst possible
|
||||
// time to be blind to which counters are alive.
|
||||
|
||||
// posPool is a fixed set of workers reading a bounded queue.
|
||||
type posPool struct {
|
||||
name string
|
||||
jobs chan func()
|
||||
wg sync.WaitGroup
|
||||
once sync.Once
|
||||
|
||||
// Guards the transition to closed. A plain `select` over a done-channel and
|
||||
// the job channel is not enough: once both are ready Go picks between them
|
||||
// at random, and picking the send panics on a closed channel. Held for
|
||||
// reading across the whole of submit, so stop cannot close the queue out
|
||||
// from under a send already in progress.
|
||||
mu sync.RWMutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newPosPool(name string, workers, queue int) *posPool {
|
||||
p := &posPool{
|
||||
name: name,
|
||||
jobs: make(chan func(), queue),
|
||||
}
|
||||
|
||||
p.wg.Add(workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
go func() {
|
||||
defer p.wg.Done()
|
||||
for job := range p.jobs {
|
||||
job()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
log.Printf("pos: %s pool started with %d workers, queue %d", name, workers, queue)
|
||||
return p
|
||||
}
|
||||
|
||||
// submit queues work, blocking when the queue is full.
|
||||
//
|
||||
// Blocking is deliberate. Dropping would lose a bill outright; the terminal
|
||||
// would eventually re-send it, but only after its ack timeout, and meanwhile we
|
||||
// would have thrown away work we had already accepted. Blocking instead pushes
|
||||
// back through paho to the broker to the till, which is exactly where the
|
||||
// decision to slow down belongs.
|
||||
func (p *posPool) submit(job func()) {
|
||||
p.mu.RLock()
|
||||
|
||||
if p.closed {
|
||||
p.mu.RUnlock()
|
||||
// Shutting down. Running it inline still gets the work done and its ack
|
||||
// published, rather than discarding a bill that already reached us.
|
||||
job()
|
||||
return
|
||||
}
|
||||
|
||||
// The read lock is held across the send. Blocking here while the queue is
|
||||
// full cannot deadlock against stop: the workers only exit once the channel
|
||||
// is closed, and that happens under the write lock this send is holding
|
||||
// off — so they stay alive and keep draining until this send completes.
|
||||
p.jobs <- job
|
||||
p.mu.RUnlock()
|
||||
}
|
||||
|
||||
// stop drains the queue and waits for in-flight work.
|
||||
//
|
||||
// Every job already accepted runs to completion, so a bill mid-commit still
|
||||
// gets its ack. Without one the terminal would hold it and send it again on
|
||||
// restart — harmless, but avoidable.
|
||||
func (p *posPool) stop() {
|
||||
p.once.Do(func() {
|
||||
// The write lock waits for every submit already in progress, so the
|
||||
// channel is never closed while something is mid-send.
|
||||
p.mu.Lock()
|
||||
p.closed = true
|
||||
close(p.jobs)
|
||||
p.mu.Unlock()
|
||||
|
||||
p.wg.Wait()
|
||||
log.Printf("pos: %s pool drained", p.name)
|
||||
})
|
||||
}
|
||||
|
||||
// posPoolSize reads a worker count from the environment.
|
||||
//
|
||||
// The default is deliberately modest. Each worker holds a database transaction
|
||||
// while it runs, so the useful ceiling is the Postgres connection pool, not the
|
||||
// CPU — set this above what the database can serve and the workers simply queue
|
||||
// inside the driver instead, where there is no backpressure to feel.
|
||||
func posPoolSize(key string, fallback int) int {
|
||||
v, err := strconv.Atoi(os.Getenv(key))
|
||||
if err != nil || v <= 0 {
|
||||
return fallback
|
||||
}
|
||||
if v > 128 {
|
||||
return 128
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// posOrderedDelivery reports whether paho should preserve message order.
|
||||
//
|
||||
// Left on: paho then delivers on one goroutine, which hands work to the pool
|
||||
// and blocks when it is full. That single delivery goroutine is what makes
|
||||
// backpressure reach the broker at all — with concurrent delivery paho would
|
||||
// keep reading regardless of how far behind the workers were.
|
||||
const posOrderedDelivery = true
|
||||
|
||||
// wrapHandler puts a paho message handler behind a pool.
|
||||
//
|
||||
// The payload is copied because paho reuses its buffer once the handler
|
||||
// returns, and the work now happens after that.
|
||||
func wrapHandler(pool *posPool, h mqtt.MessageHandler) mqtt.MessageHandler {
|
||||
return func(client mqtt.Client, msg mqtt.Message) {
|
||||
topic := msg.Topic()
|
||||
payload := make([]byte, len(msg.Payload()))
|
||||
copy(payload, msg.Payload())
|
||||
|
||||
pool.submit(func() {
|
||||
h(client, copiedMessage{topic: topic, payload: payload})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// copiedMessage carries a payload that outlives paho's buffer.
|
||||
type copiedMessage struct {
|
||||
topic string
|
||||
payload []byte
|
||||
}
|
||||
|
||||
func (m copiedMessage) Duplicate() bool { return false }
|
||||
func (m copiedMessage) Qos() byte { return 1 }
|
||||
func (m copiedMessage) Retained() bool { return false }
|
||||
func (m copiedMessage) Topic() string { return m.topic }
|
||||
func (m copiedMessage) MessageID() uint16 { return 0 }
|
||||
func (m copiedMessage) Payload() []byte { return m.payload }
|
||||
func (m copiedMessage) Ack() {}
|
||||
247
messaging/posworkers_test.go
Normal file
247
messaging/posworkers_test.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
func TestEveryJobRuns(t *testing.T) {
|
||||
pool := newPosPool("test", 4, 16)
|
||||
|
||||
var done int64
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
pool.submit(func() {
|
||||
defer wg.Done()
|
||||
atomic.AddInt64(&done, 1)
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
pool.stop()
|
||||
|
||||
if got := atomic.LoadInt64(&done); got != 100 {
|
||||
t.Errorf("ran %d jobs, want 100", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrencyIsBounded(t *testing.T) {
|
||||
// The reason the pool exists. Unbounded concurrency would open a database
|
||||
// transaction per message and exhaust the connection pool under a storm,
|
||||
// stalling every one of them at once.
|
||||
const workers = 4
|
||||
pool := newPosPool("test", workers, 64)
|
||||
|
||||
var inFlight, peak int64
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < 200; i++ {
|
||||
wg.Add(1)
|
||||
pool.submit(func() {
|
||||
defer wg.Done()
|
||||
|
||||
now := atomic.AddInt64(&inFlight, 1)
|
||||
for {
|
||||
was := atomic.LoadInt64(&peak)
|
||||
if now <= was || atomic.CompareAndSwapInt64(&peak, was, now) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
atomic.AddInt64(&inFlight, -1)
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
pool.stop()
|
||||
|
||||
if got := atomic.LoadInt64(&peak); got > workers {
|
||||
t.Errorf("peak concurrency %d exceeded the %d workers", got, workers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitBlocksRatherThanDroppingWork(t *testing.T) {
|
||||
// A full queue must slow the caller down, not discard a bill. Dropping
|
||||
// would throw away work already accepted from the broker, and the terminal
|
||||
// would only find out at its ack timeout.
|
||||
pool := newPosPool("test", 1, 1)
|
||||
|
||||
release := make(chan struct{})
|
||||
var ran int64
|
||||
|
||||
// Occupy the single worker.
|
||||
pool.submit(func() {
|
||||
<-release
|
||||
atomic.AddInt64(&ran, 1)
|
||||
})
|
||||
|
||||
// Fill the queue, then a third submit must block until the worker frees up.
|
||||
pool.submit(func() { atomic.AddInt64(&ran, 1) })
|
||||
|
||||
blocked := make(chan struct{})
|
||||
go func() {
|
||||
pool.submit(func() { atomic.AddInt64(&ran, 1) })
|
||||
close(blocked)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-blocked:
|
||||
t.Fatal("submit returned while the queue was full; work would be dropped under load")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// Correctly blocked.
|
||||
}
|
||||
|
||||
close(release)
|
||||
|
||||
select {
|
||||
case <-blocked:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("submit never unblocked after the worker freed up")
|
||||
}
|
||||
|
||||
pool.stop()
|
||||
|
||||
if got := atomic.LoadInt64(&ran); got != 3 {
|
||||
t.Errorf("ran %d jobs, want 3 — none may be lost", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopDrainsAcceptedWork(t *testing.T) {
|
||||
// A bill mid-commit must still get its ack. Without one the terminal holds
|
||||
// it and sends it again on restart — harmless, but avoidable.
|
||||
pool := newPosPool("test", 2, 64)
|
||||
|
||||
var done int64
|
||||
for i := 0; i < 50; i++ {
|
||||
pool.submit(func() {
|
||||
time.Sleep(time.Millisecond)
|
||||
atomic.AddInt64(&done, 1)
|
||||
})
|
||||
}
|
||||
|
||||
pool.stop()
|
||||
|
||||
if got := atomic.LoadInt64(&done); got != 50 {
|
||||
t.Errorf("only %d of 50 jobs completed before shutdown finished", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitAfterStopStillRunsTheWork(t *testing.T) {
|
||||
// A message that arrived during shutdown has already been taken from the
|
||||
// broker. Discarding it would lose a bill we accepted responsibility for.
|
||||
pool := newPosPool("test", 2, 8)
|
||||
pool.stop()
|
||||
|
||||
var ran int64
|
||||
pool.submit(func() { atomic.AddInt64(&ran, 1) })
|
||||
|
||||
if got := atomic.LoadInt64(&ran); got != 1 {
|
||||
t.Error("work submitted during shutdown was discarded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopIsIdempotent(t *testing.T) {
|
||||
// Close() may be reached twice on a shutdown path; a second close of the
|
||||
// jobs channel would panic and take the process down mid-drain.
|
||||
pool := newPosPool("test", 2, 8)
|
||||
pool.stop()
|
||||
pool.stop()
|
||||
pool.stop()
|
||||
}
|
||||
|
||||
func TestPoolSizeFallsBackAndClamps(t *testing.T) {
|
||||
t.Setenv("POS_TEST_WORKERS", "")
|
||||
if got := posPoolSize("POS_TEST_WORKERS", 8); got != 8 {
|
||||
t.Errorf("unset = %d, want the fallback 8", got)
|
||||
}
|
||||
|
||||
t.Setenv("POS_TEST_WORKERS", "not a number")
|
||||
if got := posPoolSize("POS_TEST_WORKERS", 8); got != 8 {
|
||||
t.Errorf("garbage = %d, want the fallback 8", got)
|
||||
}
|
||||
|
||||
t.Setenv("POS_TEST_WORKERS", "0")
|
||||
if got := posPoolSize("POS_TEST_WORKERS", 8); got != 8 {
|
||||
t.Errorf("zero = %d, want the fallback 8", got)
|
||||
}
|
||||
|
||||
t.Setenv("POS_TEST_WORKERS", "-4")
|
||||
if got := posPoolSize("POS_TEST_WORKERS", 8); got != 8 {
|
||||
t.Errorf("negative = %d, want the fallback 8", got)
|
||||
}
|
||||
|
||||
t.Setenv("POS_TEST_WORKERS", "24")
|
||||
if got := posPoolSize("POS_TEST_WORKERS", 8); got != 24 {
|
||||
t.Errorf("explicit = %d, want 24", got)
|
||||
}
|
||||
|
||||
// Clamped: more workers than the database can serve just moves the queue
|
||||
// inside the driver, where there is no backpressure to feel.
|
||||
t.Setenv("POS_TEST_WORKERS", "100000")
|
||||
if got := posPoolSize("POS_TEST_WORKERS", 8); got != 128 {
|
||||
t.Errorf("absurd = %d, want the 128 clamp", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWrappedHandlerCopiesThePayload(t *testing.T) {
|
||||
// paho reuses its buffer once a handler returns, and with a pool the work
|
||||
// now happens *after* that. Without a copy a queued bill would be read as
|
||||
// whatever message happened to arrive next — silently, and as valid JSON
|
||||
// often enough to commit the wrong sale.
|
||||
pool := newPosPool("test", 1, 4)
|
||||
|
||||
seen := make(chan string, 1)
|
||||
wrapped := wrapHandler(pool, func(_ mqtt.Client, msg mqtt.Message) {
|
||||
seen <- string(msg.Payload())
|
||||
})
|
||||
|
||||
// A buffer paho would reuse.
|
||||
buffer := []byte(`{"batch_id":"original"}`)
|
||||
wrapped(nil, fakeMessage{topic: "nearle/pos/12/T4A9/order", payload: buffer})
|
||||
|
||||
// Overwrite it the instant the handler returns, exactly as paho would.
|
||||
for i := range buffer {
|
||||
buffer[i] = 'X'
|
||||
}
|
||||
|
||||
select {
|
||||
case got := <-seen:
|
||||
if got != `{"batch_id":"original"}` {
|
||||
t.Errorf("handler saw %q — the payload was not copied before queueing", got)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("the wrapped handler never ran")
|
||||
}
|
||||
|
||||
pool.stop()
|
||||
}
|
||||
|
||||
func TestAWrappedHandlerKeepsTheTopic(t *testing.T) {
|
||||
// Store and terminal are read from the topic, never the body. Losing it in
|
||||
// the hand-off would leave the ack with nowhere to go.
|
||||
pool := newPosPool("test", 1, 4)
|
||||
|
||||
seen := make(chan string, 1)
|
||||
wrapped := wrapHandler(pool, func(_ mqtt.Client, msg mqtt.Message) {
|
||||
seen <- msg.Topic()
|
||||
})
|
||||
|
||||
wrapped(nil, fakeMessage{topic: "nearle/pos/1135/T4A9/order", payload: []byte("{}")})
|
||||
|
||||
select {
|
||||
case got := <-seen:
|
||||
if got != "nearle/pos/1135/T4A9/order" {
|
||||
t.Errorf("topic = %q, want nearle/pos/1135/T4A9/order", got)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("the wrapped handler never ran")
|
||||
}
|
||||
|
||||
pool.stop()
|
||||
}
|
||||
@@ -136,3 +136,74 @@ type PosOrderItems struct {
|
||||
func (PosOrderItems) TableName() string {
|
||||
return "pos_order_items"
|
||||
}
|
||||
|
||||
// PosSalesFilter scopes a query over counter sales.
|
||||
//
|
||||
// Locationid is required and is the authorisation boundary — every read is
|
||||
// scoped to one outlet, so a caller cannot page through another shop's takings
|
||||
// by omitting a parameter.
|
||||
type PosSalesFilter struct {
|
||||
Locationid int
|
||||
Fromdate string // YYYY-MM-DD, matched against businessdate
|
||||
Todate string
|
||||
Terminalid string
|
||||
Cashiername string
|
||||
Paymentmode string
|
||||
Pageno int
|
||||
Pagesize int
|
||||
}
|
||||
|
||||
// PosSalesPage is one page of bills, with the total so a caller can paginate
|
||||
// without a second request.
|
||||
type PosSalesPage struct {
|
||||
Total int64 `json:"total"`
|
||||
Pageno int `json:"pageno"`
|
||||
Pagesize int `json:"pagesize"`
|
||||
Bills []PosOrders `json:"bills"`
|
||||
}
|
||||
|
||||
// PosSalesSummary totals a range of counter sales.
|
||||
//
|
||||
// Deliberately separate from the bill list: a shop settling a till wants the
|
||||
// figures, not five hundred rows, and computing them client-side would mean
|
||||
// fetching every page first.
|
||||
type PosSalesSummary struct {
|
||||
Locationid int `json:"locationid"`
|
||||
Fromdate string `json:"fromdate"`
|
||||
Todate string `json:"todate"`
|
||||
Billcount int `json:"billcount"`
|
||||
Itemcount int `json:"itemcount"`
|
||||
Grosssales float64 `json:"grosssales"`
|
||||
Taxcollected float64 `json:"taxcollected"`
|
||||
Discount float64 `json:"discountgiven"`
|
||||
Roundoff float64 `json:"roundoff"`
|
||||
Averagebill float64 `json:"averagebill"`
|
||||
|
||||
// What a cashier reconciles the drawer against.
|
||||
Bypaymentmode []PosPaymentTotal `json:"bypaymentmode"`
|
||||
|
||||
// One row per trading day, for a chart.
|
||||
Byday []PosDayTotal `json:"byday"`
|
||||
|
||||
// Which tills contributed, so an outlet with several counters can see them
|
||||
// apart without a second query.
|
||||
Byterminal []PosTerminalTotal `json:"byterminal"`
|
||||
}
|
||||
|
||||
type PosPaymentTotal struct {
|
||||
Paymentmode string `json:"paymentmode"`
|
||||
Billcount int `json:"billcount"`
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
type PosDayTotal struct {
|
||||
Businessdate string `json:"businessdate"`
|
||||
Billcount int `json:"billcount"`
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
type PosTerminalTotal struct {
|
||||
Terminalid string `json:"terminalid"`
|
||||
Billcount int `json:"billcount"`
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
@@ -77,8 +77,16 @@ type Products struct {
|
||||
Productcombo int `json:"productcombo" gorm:"default:0"`
|
||||
Variants int `json:"variants" gorm:"default:0"`
|
||||
Quantity int `json:"quantity"`
|
||||
Retailprice float64 `json:"retailprice,omitempty"`
|
||||
Diffprice float64 `json:"diffprice,omitempty"`
|
||||
// Price is the EFFECTIVE selling price at the location a query was scoped
|
||||
// to: productlocations.price when the store has set one, otherwise the
|
||||
// master Retailprice below. Read-only — it is computed by the query, never
|
||||
// written through this struct. Location-scoped endpoints must expose it, or
|
||||
// a price the admin sets per store can never reach the customer app: they
|
||||
// returned only Retailprice, which the admin catalogue never writes.
|
||||
// Same meaning as Locationproducts.Price, so both product feeds agree.
|
||||
Price float64 `json:"price" gorm:"->"`
|
||||
Retailprice float64 `json:"retailprice,omitempty"`
|
||||
Diffprice float64 `json:"diffprice,omitempty"`
|
||||
Diffpercent float64 `json:"diffpercent,omitempty"`
|
||||
Othercost float64 `json:"othercost,omitempty"`
|
||||
Approve int `json:"approve"`
|
||||
|
||||
@@ -1383,6 +1383,110 @@ func (r *orderRepository) reloadOrder(orderHeaderID int) (models.Orders, error)
|
||||
// use tx again. On success tx is left open and uncommitted, so the caller can
|
||||
// include its own work — a duplicate-bill guard, an advisory lock — in the same
|
||||
// transaction as the order that work protects.
|
||||
// priceOrderLines fills in any line the client sent without a price, using the
|
||||
// merchant's own catalogue, and brings the header totals in line with the
|
||||
// result. It mutates data in place and is a no-op for an order that already
|
||||
// arrived fully priced.
|
||||
//
|
||||
// The arithmetic deliberately matches the offline-sales import exactly — gross,
|
||||
// minus discount, with tax extracted from the resulting landing amount because
|
||||
// shelf prices here are MRP (tax already inside). One convention for both
|
||||
// channels, so the same basket rings up the same either way.
|
||||
func (r *orderRepository) priceOrderLines(tx *gorm.DB, data *models.Orders, defaultLocID int) error {
|
||||
if len(data.Items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// One catalogue read per outlet, not per line. Items usually share an
|
||||
// outlet, but a line may name its own.
|
||||
catalogues := make(map[int]map[int]offlineProduct)
|
||||
catalogueFor := func(locationID int) (map[int]offlineProduct, error) {
|
||||
if c, ok := catalogues[locationID]; ok {
|
||||
return c, nil
|
||||
}
|
||||
c, err := loadCatalogueProducts(tx, data.Tenantid, locationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
catalogues[locationID] = c
|
||||
return c, nil
|
||||
}
|
||||
|
||||
var lineTotal, taxTotal float64
|
||||
|
||||
for i := range data.Items {
|
||||
item := &data.Items[i]
|
||||
|
||||
itemLocID := item.Locationid
|
||||
if itemLocID == 0 {
|
||||
itemLocID = defaultLocID
|
||||
}
|
||||
|
||||
if item.Price <= 0 {
|
||||
catalogue, err := catalogueFor(itemLocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A miss can't normally happen — the stock check above already
|
||||
// proved the product is stocked here. If it somehow does, leave the
|
||||
// line as the client sent it rather than refusing the order: a
|
||||
// pricing lookup is not a reason to block a customer's checkout.
|
||||
if product, ok := catalogue[item.Productid]; ok {
|
||||
item.Price = product.Price
|
||||
if item.Taxpercentage <= 0 {
|
||||
item.Taxpercentage = product.Taxpercent
|
||||
}
|
||||
if item.Productname == "" {
|
||||
item.Productname = product.Productname
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gross := item.Price * item.Orderqty
|
||||
discount := item.Discountamount
|
||||
if discount < 0 {
|
||||
discount = 0
|
||||
}
|
||||
if discount > gross {
|
||||
discount = gross
|
||||
}
|
||||
landing := gross - discount
|
||||
|
||||
// Only derive what the client didn't state, so a client that does its
|
||||
// own (possibly promotional) maths keeps its figures.
|
||||
if item.Productsumprice <= 0 {
|
||||
item.Productsumprice = gross
|
||||
}
|
||||
if item.Landingamount <= 0 {
|
||||
item.Landingamount = landing
|
||||
}
|
||||
if item.Taxamount <= 0 && item.Taxpercentage > 0 {
|
||||
item.Taxamount = landing - (landing / (1 + item.Taxpercentage/100))
|
||||
}
|
||||
|
||||
lineTotal += item.Landingamount
|
||||
taxTotal += item.Taxamount
|
||||
}
|
||||
|
||||
// Header totals are only derived when the client left them empty; an order
|
||||
// that states its own total (delivery charges, promotions applied basket-
|
||||
// wide) keeps it.
|
||||
if data.Orderamount <= 0 {
|
||||
data.Orderamount = float32(lineTotal)
|
||||
}
|
||||
if data.Ordervalue <= 0 {
|
||||
data.Ordervalue = float32(lineTotal)
|
||||
}
|
||||
if data.Taxamount <= 0 {
|
||||
data.Taxamount = float32(taxTotal)
|
||||
}
|
||||
if data.Itemcount <= 0 {
|
||||
data.Itemcount = len(data.Items)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *orderRepository) createOrderTx(tx *gorm.DB, data models.Orders) (models.Orders, error) {
|
||||
locID := data.Locationid
|
||||
if locID == 0 {
|
||||
@@ -1424,6 +1528,22 @@ func (r *orderRepository) createOrderTx(tx *gorm.DB, data models.Orders) (models
|
||||
return models.Orders{}, err
|
||||
}
|
||||
|
||||
// 🛠️ Step 1b: Price the lines the client left unpriced.
|
||||
//
|
||||
// Line prices arrive from the client, and a client that sends none books the
|
||||
// order at zero — which is exactly what happened to every catalogue-imported
|
||||
// product, whose per-store price was never set: real orders were written
|
||||
// with price 0 and orderamount 0, so a delivered sale recorded no revenue.
|
||||
//
|
||||
// Only lines the client left at or below zero are filled. A line that came
|
||||
// with a price keeps it, because variants, addons and promotions legitimately
|
||||
// charge something other than the shelf price and this is not the place to
|
||||
// second-guess them.
|
||||
if err := r.priceOrderLines(tx, &data, locID); err != nil {
|
||||
tx.Rollback()
|
||||
return models.Orders{}, err
|
||||
}
|
||||
|
||||
// 🛠️ Step 2: Create Order Header
|
||||
// Claimed inside tx so the row lock on the counter holds until commit:
|
||||
// concurrent orders queue for it instead of reading the same number, and a
|
||||
@@ -1632,8 +1752,21 @@ type offlineProduct struct {
|
||||
// way the line is refused — so a hand-edited productid cannot reach into a
|
||||
// catalogue the uploader has no claim on.
|
||||
func (r *orderRepository) loadOfflineProducts(tenantID, locationID int) (map[int]offlineProduct, error) {
|
||||
return loadCatalogueProducts(r.db, tenantID, locationID)
|
||||
}
|
||||
|
||||
// loadCatalogueProducts is the shared price/tax lookup: the merchant's own
|
||||
// selling price for every product stocked at one outlet, preferring the
|
||||
// per-store productlocations.price and falling back to the master
|
||||
// products.retailprice. Both order paths price from this one query so an online
|
||||
// order and a counter sale can never disagree about what a product costs.
|
||||
//
|
||||
// Takes its handle so a caller inside a transaction reads through that
|
||||
// transaction — createOrderTx has already locked these product rows, and
|
||||
// reading around the lock would defeat the point.
|
||||
func loadCatalogueProducts(db *gorm.DB, tenantID, locationID int) (map[int]offlineProduct, error) {
|
||||
rows := make([]offlineProduct, 0)
|
||||
err := r.db.Raw(`
|
||||
err := db.Raw(`
|
||||
SELECT a.productid,
|
||||
COALESCE(a.productname, '') AS productname,
|
||||
COALESCE(a.productunit, '') AS productunit,
|
||||
|
||||
@@ -37,6 +37,12 @@ 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)
|
||||
|
||||
// Reading counter sales back out. Without these a committed bill is
|
||||
// unreachable from every screen in the product.
|
||||
Sales(f models.PosSalesFilter) (*models.PosSalesPage, error)
|
||||
SaleDetail(locationID int, reference string) (*models.PosOrders, error)
|
||||
SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error)
|
||||
}
|
||||
|
||||
type posRepository struct {
|
||||
@@ -106,7 +112,7 @@ func (r *posRepository) IngestOrders(batch models.PosOrderBatch) (*models.PosAck
|
||||
ack.Reject("", "order is missing its id")
|
||||
continue
|
||||
}
|
||||
if reason := r.importPosOrder(ctx, products, batch.Batchid, order); reason != "" {
|
||||
if reason := r.importPosOrder(ctx, products, batch.Batchid, batch.Terminalid, order); reason != "" {
|
||||
ack.Reject(order.Id, reason)
|
||||
continue
|
||||
}
|
||||
@@ -126,10 +132,15 @@ func (r *posRepository) IngestOrders(batch models.PosOrderBatch) (*models.PosAck
|
||||
// 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.
|
||||
// batchTerminal is the terminal the whole batch came from, used when a bill
|
||||
// does not name one itself. Over MQTT the consumer fills it in from the topic;
|
||||
// over HTTP the terminal sends it once at the top of the batch rather than
|
||||
// repeating it on every bill.
|
||||
func (r *posRepository) importPosOrder(
|
||||
ctx *offlineLocationContext,
|
||||
products map[int]offlineProduct,
|
||||
batchID string,
|
||||
batchTerminal string,
|
||||
order models.PosOrder,
|
||||
) string {
|
||||
if len(order.Items) == 0 {
|
||||
@@ -296,7 +307,11 @@ func (r *posRepository) importPosOrder(
|
||||
Invoicenumber: order.Invoicenumber,
|
||||
Tenantid: ctx.Tenantid,
|
||||
Locationid: ctx.Locationid,
|
||||
Terminalid: order.Terminalid,
|
||||
// The bill's own terminal wins; the batch's is the fallback. Without
|
||||
// this the column was empty on every bill that arrived over HTTP —
|
||||
// the invoice number carried the code and the column did not, so
|
||||
// per-terminal reconciliation had nothing to group on.
|
||||
Terminalid: posTerminalFor(order.Terminalid, batchTerminal),
|
||||
Cashiername: order.Cashier,
|
||||
Customerid: customerID,
|
||||
Customermobile: posCustomerMobile(order),
|
||||
@@ -366,6 +381,18 @@ func posJSON(v any) string {
|
||||
return string(body)
|
||||
}
|
||||
|
||||
// posTerminalFor picks which terminal code to file a bill under.
|
||||
//
|
||||
// Trimmed before the emptiness test: a terminal sending `" "` is saying nothing,
|
||||
// and treating that as a real code would file bills under a blank that looks
|
||||
// identical to the missing value this exists to fix.
|
||||
func posTerminalFor(orderTerminal, batchTerminal string) string {
|
||||
if t := strings.TrimSpace(orderTerminal); t != "" {
|
||||
return t
|
||||
}
|
||||
return strings.TrimSpace(batchTerminal)
|
||||
}
|
||||
|
||||
func posCustomerName(order models.PosOrder) string {
|
||||
if order.Customer == nil {
|
||||
return ""
|
||||
@@ -385,6 +412,20 @@ func posCustomerMobile(order models.PosOrder) string {
|
||||
// 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.
|
||||
// parsePosSaleDate reads the moment a bill was rung.
|
||||
//
|
||||
// The order of these layouts is load-bearing, and the two zoned ones must stay
|
||||
// first. A terminal that sends its offset — `2026-08-05T00:30:00+05:30` — gets
|
||||
// both readings right: the instant is correct, and Format("2006-01-02") still
|
||||
// yields the till's own trading day rather than UTC's.
|
||||
//
|
||||
// The two bare layouts exist for terminals built before the offset was added,
|
||||
// which are still in the field. `time.Parse` fills an absent zone with UTC, so
|
||||
// those bills record an instant wrong by the offset — a Coimbatore wall clock
|
||||
// read as though it were London. That is not recoverable here: nothing in the
|
||||
// payload says which zone it came from. Their business date is still right,
|
||||
// which is why the daily figures held up while billedat did not, and why these
|
||||
// are tolerated rather than refused.
|
||||
func parsePosSaleDate(raw string) (time.Time, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
@@ -488,12 +529,67 @@ func (r *posRepository) upsertPosCustomer(
|
||||
return nil
|
||||
}
|
||||
|
||||
// Catalogue answers a terminal's morning pull.
|
||||
// posRevisionLayout is the timestamp inside a catalogue revision.
|
||||
//
|
||||
// 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.
|
||||
// The revision is the terminal's memory of when it last pulled: it stores what
|
||||
// we send and hands it back on the next request, and the time encoded in it is
|
||||
// the cutoff for what has changed since. Colons are avoided so the whole string
|
||||
// stays safe in a URL query without escaping.
|
||||
const posRevisionLayout = "20060102T150405Z"
|
||||
|
||||
// posRevisionFor mints the revision a terminal will send back to us.
|
||||
func posRevisionFor(locationID int, at time.Time) string {
|
||||
return fmt.Sprintf("loc%d-%s", locationID, at.UTC().Format(posRevisionLayout))
|
||||
}
|
||||
|
||||
// posRevisionCutoff reads the timestamp back out of a revision.
|
||||
//
|
||||
// Returns the zero time when the revision is missing, malformed, or belongs to
|
||||
// a different outlet — and a zero cutoff means "send everything". Falling back
|
||||
// to a full snapshot is the only safe direction: answering an unreadable
|
||||
// revision with a *delta* would leave the terminal quietly missing every change
|
||||
// it had not already seen, with nothing to indicate it.
|
||||
func posRevisionCutoff(locationID int, revision string) time.Time {
|
||||
revision = strings.TrimSpace(revision)
|
||||
prefix := fmt.Sprintf("loc%d-", locationID)
|
||||
if !strings.HasPrefix(revision, prefix) {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
at, err := time.Parse(posRevisionLayout, strings.TrimPrefix(revision, prefix))
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// Catalogue answers a terminal's pull, as a snapshot or as a change set.
|
||||
//
|
||||
// ### The rule this function exists to keep
|
||||
//
|
||||
// A response with `is_delta: false` is treated as a full snapshot, and the
|
||||
// terminal **withdraws every product the response does not mention**. So a
|
||||
// filtered result labelled `false` empties the shop's shelf.
|
||||
//
|
||||
// The two are therefore decided together, from one value: a zero cutoff means
|
||||
// no filter and `is_delta: false`; a non-zero cutoff means filtered and
|
||||
// `is_delta: true`. There is no path through this function that filters without
|
||||
// also setting the flag.
|
||||
//
|
||||
// ### What counts as a change
|
||||
//
|
||||
// A product is included when any of three things moved since the cutoff: the
|
||||
// product row itself (name, tax, brand), its row at this location (price,
|
||||
// availability), or its stock ledger. Stock is included because a shop's count
|
||||
// drifts from the till's on every sale rung elsewhere, and a delta that omitted
|
||||
// it would let that drift persist until someone forced a full pull.
|
||||
//
|
||||
// ### What a delta cannot do
|
||||
//
|
||||
// A product *deleted* from productlocations leaves no tombstone, so a change set
|
||||
// cannot know to withdraw it. Only a full snapshot collects those. A terminal
|
||||
// should pull without a revision periodically — the morning import is the
|
||||
// natural moment — and this is why.
|
||||
func (r *posRepository) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) {
|
||||
ctx, err := r.resolvePosStore(storeID)
|
||||
if err != nil {
|
||||
@@ -507,6 +603,11 @@ func (r *posRepository) Catalogue(storeID, since string, page, pageSize int) (*m
|
||||
page = 0
|
||||
}
|
||||
|
||||
// The single decision. Everything downstream reads this rather than
|
||||
// re-deriving it, so the filter and the flag cannot disagree.
|
||||
cutoff := posRevisionCutoff(ctx.Locationid, since)
|
||||
isDelta := !cutoff.IsZero()
|
||||
|
||||
type row struct {
|
||||
Productid int
|
||||
Productname string
|
||||
@@ -521,8 +622,25 @@ func (r *posRepository) Catalogue(storeID, since string, page, pageSize int) (*m
|
||||
Status string
|
||||
}
|
||||
|
||||
// A product counts as changed if the product row, its row at this location,
|
||||
// or its stock ledger moved. Written as one predicate so a delta cannot
|
||||
// miss a price change simply because the product row was untouched.
|
||||
changed := ""
|
||||
params := []interface{}{ctx.Tenantid, ctx.Locationid}
|
||||
if isDelta {
|
||||
changed = `AND (
|
||||
a.updated >= ?
|
||||
OR b.updated >= ?
|
||||
OR EXISTS (SELECT 1 FROM productstocks s2
|
||||
WHERE s2.productid = a.productid AND s2.tenantid = a.tenantid
|
||||
AND s2.locationid = b.locationid
|
||||
AND (s2.stockdate >= ? OR s2.updated >= ?))
|
||||
)`
|
||||
params = append(params, cutoff, cutoff, cutoff, cutoff)
|
||||
}
|
||||
|
||||
rows := make([]row, 0)
|
||||
err = r.db.Raw(`
|
||||
query := fmt.Sprintf(`
|
||||
SELECT a.productid,
|
||||
COALESCE(a.productname, '') AS productname,
|
||||
COALESCE(a.productsku, '') AS productsku,
|
||||
@@ -542,12 +660,15 @@ func (r *posRepository) Catalogue(storeID, since string, page, pageSize int) (*m
|
||||
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 = ?
|
||||
WHERE a.tenantid = ? AND b.locationid = ? AND a.productid > 0 %s
|
||||
ORDER BY a.productid
|
||||
LIMIT ? OFFSET ?`,
|
||||
ctx.Tenantid, ctx.Locationid, pageSize+1, page*pageSize,
|
||||
).Scan(&rows).Error
|
||||
if err != nil {
|
||||
LIMIT ? OFFSET ?`, changed)
|
||||
|
||||
// One row past the page, purely so has_more can be answered without a
|
||||
// second count query.
|
||||
params = append(params, pageSize+1, page*pageSize)
|
||||
|
||||
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -560,14 +681,12 @@ func (r *posRepository) Catalogue(storeID, since string, page, pageSize int) (*m
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Rows with productid <= 0 are excluded in SQL rather than here. Live
|
||||
// data has at least one — almost certainly an insert that never got a
|
||||
// sequence value — and it can never be billed, because the ingest
|
||||
// refuses any line whose id is not positive. Filtering it in the query
|
||||
// also keeps pagination exact: skipped after the LIMIT, it would eat a
|
||||
// slot and hand back a short page.
|
||||
mrp := p.Retailprice
|
||||
if mrp <= p.Price {
|
||||
mrp = 0
|
||||
@@ -606,15 +725,39 @@ func (r *posRepository) Catalogue(storeID, since string, page, pageSize int) (*m
|
||||
})
|
||||
}
|
||||
|
||||
// The revision only advances on the final page.
|
||||
//
|
||||
// A terminal that gives up half way through a paginated pull — a dropped
|
||||
// connection, a till switched off — must not be left holding a revision
|
||||
// that claims it has seen pages it never received. Every one of those
|
||||
// products would then be excluded from the next delta and stay stale
|
||||
// indefinitely, with nothing anywhere to indicate it.
|
||||
//
|
||||
// So mid-pull we echo back whatever the terminal already had: unchanged if
|
||||
// it sent one, empty if it did not, and empty means the next pull is a full
|
||||
// snapshot. Both are recoverable; a prematurely advanced revision is not.
|
||||
//
|
||||
// The stamp is taken a second in the past. A product written during the
|
||||
// same second this query ran could otherwise land on the wrong side of the
|
||||
// next cutoff and be skipped for good — overlapping by a second costs one
|
||||
// redundant row and cannot lose one.
|
||||
revision := strings.TrimSpace(since)
|
||||
if !hasMore {
|
||||
revision = posRevisionFor(ctx.Locationid, time.Now().Add(-time.Second))
|
||||
}
|
||||
|
||||
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),
|
||||
Revision: revision,
|
||||
// Decided with the filter, never separately. False here would tell the
|
||||
// terminal to withdraw every product this response omits.
|
||||
Isdelta: isDelta,
|
||||
Hasmore: hasMore,
|
||||
Products: products,
|
||||
Customers: make([]models.PosCatalogueCustomer, 0),
|
||||
// A product deleted from productlocations leaves no tombstone, so a
|
||||
// change set cannot know to withdraw it. Only a full snapshot collects
|
||||
// those, which is why a terminal should pull without a revision
|
||||
// periodically.
|
||||
Retiredids: make([]string, 0),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package repositories
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -95,3 +98,156 @@ func TestLegacyOrderQtyIsUnchanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A catalogue revision is the terminal's memory of when it last pulled. If it
|
||||
// does not survive a round trip, every pull silently becomes a full snapshot —
|
||||
// or worse, a filtered result gets labelled as one and the shop's shelf empties.
|
||||
func TestPosRevisionRoundTrips(t *testing.T) {
|
||||
at := time.Date(2026, 8, 3, 12, 30, 45, 0, time.UTC)
|
||||
|
||||
revision := posRevisionFor(1135, at)
|
||||
if revision != "loc1135-20260803T123045Z" {
|
||||
t.Fatalf("revision = %q, want loc1135-20260803T123045Z", revision)
|
||||
}
|
||||
|
||||
got := posRevisionCutoff(1135, revision)
|
||||
if !got.Equal(at) {
|
||||
t.Errorf("cutoff = %v, want %v", got, at)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnusableRevisionFallsBackToAFullSnapshot(t *testing.T) {
|
||||
// A zero cutoff means "send everything", and the caller turns that into
|
||||
// is_delta:false. Falling back the other way — answering an unreadable
|
||||
// revision with a change set — would leave a terminal permanently missing
|
||||
// every change it had not already seen, with nothing to show for it.
|
||||
cases := []struct {
|
||||
name string
|
||||
location int
|
||||
revision string
|
||||
}{
|
||||
{"empty", 1135, ""},
|
||||
{"whitespace", 1135, " "},
|
||||
{"no prefix", 1135, "20260803T123045Z"},
|
||||
{"malformed timestamp", 1135, "loc1135-not-a-time"},
|
||||
{"truncated timestamp", 1135, "loc1135-20260803"},
|
||||
{"another outlet's revision", 1135, "loc1097-20260803T123045Z"},
|
||||
{"prefix collision", 113, "loc1135-20260803T123045Z"},
|
||||
{"garbage", 1135, "../../etc/passwd"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := posRevisionCutoff(c.location, c.revision); !got.IsZero() {
|
||||
t.Errorf("cutoff = %v, want zero (full snapshot) for %q", got, c.revision)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOutletCannotReplayAnotherOutletsRevision(t *testing.T) {
|
||||
// loc1135 and loc113 share a textual prefix. Matching loosely would let one
|
||||
// shop's cutoff silently scope another shop's delta.
|
||||
at := time.Date(2026, 8, 3, 12, 30, 45, 0, time.UTC)
|
||||
revision := posRevisionFor(1135, at)
|
||||
|
||||
if got := posRevisionCutoff(1135, revision); got.IsZero() {
|
||||
t.Error("the issuing outlet could not read back its own revision")
|
||||
}
|
||||
for _, other := range []int{113, 11350, 1097, 1} {
|
||||
if got := posRevisionCutoff(other, revision); !got.IsZero() {
|
||||
t.Errorf("outlet %d accepted outlet 1135's revision (cutoff %v)", other, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The bug this covers reached production and stayed invisible for a day.
|
||||
//
|
||||
// The MQTT consumer backfills a missing terminal code from the topic, but it
|
||||
// wrote it onto the *batch* while the row was built from the *order*, so the
|
||||
// two never met. Bills arriving over HTTP had no topic to fall back on at all.
|
||||
// The result: 16 of 17 live bills carried an empty terminalid while their own
|
||||
// invoice numbers read INV-2608-T5EDD-000NN, and `byterminal` on the sales
|
||||
// summary grouped almost everything under "".
|
||||
func TestABillTakesItsTerminalFromTheBatchWhenItNamesNone(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
orderTerminal string
|
||||
batchTerminal string
|
||||
want string
|
||||
}{
|
||||
{"bill names its own", "T5EDD", "TOTHER", "T5EDD"},
|
||||
{"bill is silent, batch knows", "", "T5EDD", "T5EDD"},
|
||||
{"neither knows", "", "", ""},
|
||||
|
||||
// Whitespace is not a terminal code. Treating it as one would file
|
||||
// bills under a blank that reads identically to the missing value
|
||||
// this fallback exists to prevent.
|
||||
{"bill sends whitespace", " ", "T5EDD", "T5EDD"},
|
||||
{"batch sends whitespace", "", " ", ""},
|
||||
{"codes are trimmed", " T5EDD ", "", "T5EDD"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := posTerminalFor(c.orderTerminal, c.batchTerminal); got != c.want {
|
||||
t.Errorf("posTerminalFor(%q, %q) = %q, want %q",
|
||||
c.orderTerminal, c.batchTerminal, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// billedat and businessdate are derived from the same parsed value and pull in
|
||||
// opposite directions, so they are tested together.
|
||||
//
|
||||
// Live bill INV-2608-T5EDD-00116 carried billedat 2026-08-05T12:49:28Z beside
|
||||
// receivedat 2026-08-05T07:19:28Z — the sale appearing to happen five and a
|
||||
// half hours after it was received. The till was sending a naive local
|
||||
// timestamp and time.Parse fills that silence with UTC, so a Coimbatore wall
|
||||
// clock was recorded as though read in London.
|
||||
//
|
||||
// The daily figures survived it by luck: businessdate comes off the wall clock
|
||||
// either way, and the wall clock was always the till's own. Anything comparing
|
||||
// billedat against real time did not.
|
||||
func TestASaleDateKeepsBothTheInstantAndTheTradingDay(t *testing.T) {
|
||||
// Coimbatore, late enough that UTC has not yet rolled into the same day.
|
||||
const ist = "2026-08-05T00:30:00+05:30"
|
||||
|
||||
at, err := parsePosSaleDate(ist)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePosSaleDate(%q) errored: %v", ist, err)
|
||||
}
|
||||
|
||||
// The instant. 00:30 IST is 19:00 UTC the previous evening.
|
||||
wantInstant := time.Date(2026, 8, 4, 19, 0, 0, 0, time.UTC)
|
||||
if !at.UTC().Equal(wantInstant) {
|
||||
t.Errorf("instant = %v, want %v", at.UTC(), wantInstant)
|
||||
}
|
||||
|
||||
// The trading day. This is the one that must NOT follow UTC — the shop rang
|
||||
// this sale on the 5th and its takings belong to the 5th. Deriving the
|
||||
// business date from UTC would file it under the 4th and leave two days
|
||||
// wrong: one short, one over.
|
||||
if got := at.Format("2006-01-02"); got != "2026-08-05" {
|
||||
t.Errorf("businessdate = %s, want 2026-08-05 — the till's own day", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Terminals built before the offset was added send a bare local timestamp, and
|
||||
// they are still in the field. Parsing must not start refusing them.
|
||||
//
|
||||
// The instant such a bill records is wrong by the offset and cannot be
|
||||
// recovered — there is nothing in the payload that says which zone it was read
|
||||
// in. Its business date is still right, which is why the daily figures held up,
|
||||
// and why this stays a tolerated legacy rather than a rejection.
|
||||
func TestANaiveSaleDateIsStillAccepted(t *testing.T) {
|
||||
at, err := parsePosSaleDate("2026-08-05T12:49:28.245")
|
||||
if err != nil {
|
||||
t.Fatalf("a pre-offset terminal must not be refused: %v", err)
|
||||
}
|
||||
|
||||
if got := at.Format("2006-01-02"); got != "2026-08-05" {
|
||||
t.Errorf("businessdate = %s, want 2026-08-05", got)
|
||||
}
|
||||
}
|
||||
|
||||
223
repositories/posSalesRepository.go
Normal file
223
repositories/posSalesRepository.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"nearle/models"
|
||||
)
|
||||
|
||||
// Reading counter sales back out.
|
||||
//
|
||||
// The ingest side of this package only ever writes. Without these, a bill that
|
||||
// reached pos_orders was invisible to every screen in the product — the data
|
||||
// was safe and unreachable, which is its own kind of lost.
|
||||
//
|
||||
// Every query is scoped to one locationid. That is the authorisation boundary:
|
||||
// a caller who omits it gets an error rather than a page through somebody
|
||||
// else's takings.
|
||||
|
||||
// posSalesWhere builds the shared filter, so the list, the detail and the
|
||||
// summary can never disagree about what "this outlet in this range" means.
|
||||
func posSalesWhere(f models.PosSalesFilter) (string, []interface{}) {
|
||||
where := "locationid = ?"
|
||||
params := []interface{}{f.Locationid}
|
||||
|
||||
// Matched on businessdate — the day the sale was rung, not the day it
|
||||
// reached us. A till that was offline overnight uploads yesterday's bills
|
||||
// this morning and they belong to yesterday.
|
||||
if f.Fromdate != "" && f.Todate != "" {
|
||||
where += " AND businessdate BETWEEN ? AND ?"
|
||||
params = append(params, f.Fromdate, f.Todate)
|
||||
} else if f.Fromdate != "" {
|
||||
where += " AND businessdate >= ?"
|
||||
params = append(params, f.Fromdate)
|
||||
} else if f.Todate != "" {
|
||||
where += " AND businessdate <= ?"
|
||||
params = append(params, f.Todate)
|
||||
}
|
||||
|
||||
if t := strings.TrimSpace(f.Terminalid); t != "" {
|
||||
where += " AND terminalid = ?"
|
||||
params = append(params, t)
|
||||
}
|
||||
if c := strings.TrimSpace(f.Cashiername); c != "" {
|
||||
where += " AND cashiername = ?"
|
||||
params = append(params, c)
|
||||
}
|
||||
if p := strings.TrimSpace(f.Paymentmode); p != "" {
|
||||
where += " AND LOWER(paymentmode) = ?"
|
||||
params = append(params, strings.ToLower(p))
|
||||
}
|
||||
|
||||
return where, params
|
||||
}
|
||||
|
||||
// Sales returns a page of bills, newest first, with the total count.
|
||||
//
|
||||
// Line items are deliberately not included: a page of fifty bills would drag
|
||||
// several hundred rows behind it, and a list screen shows none of them. Use
|
||||
// SaleDetail for one bill.
|
||||
func (r *posRepository) Sales(f models.PosSalesFilter) (*models.PosSalesPage, error) {
|
||||
if f.Locationid <= 0 {
|
||||
return nil, fmt.Errorf("locationid is required")
|
||||
}
|
||||
|
||||
if f.Pagesize <= 0 || f.Pagesize > 500 {
|
||||
f.Pagesize = 50
|
||||
}
|
||||
if f.Pageno < 0 {
|
||||
f.Pageno = 0
|
||||
}
|
||||
|
||||
where, params := posSalesWhere(f)
|
||||
|
||||
var total int64
|
||||
if err := r.db.Raw(
|
||||
fmt.Sprintf(`SELECT COUNT(*) FROM pos_orders WHERE %s`, where),
|
||||
params...,
|
||||
).Scan(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bills := make([]models.PosOrders, 0)
|
||||
// Ordered by billedat rather than by id: a batch uploaded after an outage
|
||||
// arrives out of order, and a list sorted by arrival would interleave
|
||||
// yesterday's bills through today's.
|
||||
query := fmt.Sprintf(
|
||||
`SELECT * FROM pos_orders WHERE %s
|
||||
ORDER BY billedat DESC, posorderid DESC
|
||||
LIMIT ? OFFSET ?`, where)
|
||||
|
||||
if err := r.db.Raw(query,
|
||||
append(params, f.Pagesize, f.Pageno*f.Pagesize)...,
|
||||
).Scan(&bills).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.PosSalesPage{
|
||||
Total: total,
|
||||
Pageno: f.Pageno,
|
||||
Pagesize: f.Pagesize,
|
||||
Bills: bills,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaleDetail returns one bill with its lines.
|
||||
//
|
||||
// Accepts either the terminal's own order UUID or this backend's posorderid,
|
||||
// because a support call starts from whichever the caller happens to be looking
|
||||
// at — a receipt carries the invoice number, a log carries the UUID.
|
||||
func (r *posRepository) SaleDetail(locationID int, reference string) (*models.PosOrders, error) {
|
||||
if locationID <= 0 {
|
||||
return nil, fmt.Errorf("locationid is required")
|
||||
}
|
||||
reference = strings.TrimSpace(reference)
|
||||
if reference == "" {
|
||||
return nil, fmt.Errorf("an order id, invoice number or posorderid is required")
|
||||
}
|
||||
|
||||
var bill models.PosOrders
|
||||
err := r.db.Raw(`
|
||||
SELECT * FROM pos_orders
|
||||
WHERE locationid = ?
|
||||
AND (terminalorderid = ? OR invoicenumber = ?
|
||||
OR CAST(posorderid AS TEXT) = ?)
|
||||
LIMIT 1`,
|
||||
locationID, reference, reference, reference,
|
||||
).Scan(&bill).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bill.Posorderid == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
items := make([]models.PosOrderItems, 0)
|
||||
if err := r.db.Raw(
|
||||
`SELECT * FROM pos_order_items WHERE posorderid = ? ORDER BY posorderitemid`,
|
||||
bill.Posorderid,
|
||||
).Scan(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bill.Items = items
|
||||
|
||||
return &bill, nil
|
||||
}
|
||||
|
||||
// SalesSummary totals a range, broken out the three ways somebody actually
|
||||
// asks for: by tender, by day, and by till.
|
||||
func (r *posRepository) SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error) {
|
||||
if f.Locationid <= 0 {
|
||||
return nil, fmt.Errorf("locationid is required")
|
||||
}
|
||||
|
||||
where, params := posSalesWhere(f)
|
||||
|
||||
summary := &models.PosSalesSummary{
|
||||
Locationid: f.Locationid,
|
||||
Fromdate: f.Fromdate,
|
||||
Todate: f.Todate,
|
||||
Bypaymentmode: make([]models.PosPaymentTotal, 0),
|
||||
Byday: make([]models.PosDayTotal, 0),
|
||||
Byterminal: make([]models.PosTerminalTotal, 0),
|
||||
}
|
||||
|
||||
var head struct {
|
||||
Billcount int
|
||||
Itemcount int
|
||||
Grosssales float64
|
||||
Taxcollected float64
|
||||
Discount float64
|
||||
Roundoff float64
|
||||
}
|
||||
if err := r.db.Raw(fmt.Sprintf(`
|
||||
SELECT COUNT(*) AS billcount,
|
||||
COALESCE(SUM(itemcount), 0) AS itemcount,
|
||||
COALESCE(SUM(total), 0) AS grosssales,
|
||||
COALESCE(SUM(taxamount), 0) AS taxcollected,
|
||||
COALESCE(SUM(discount), 0) AS discount,
|
||||
COALESCE(SUM(roundoff), 0) AS roundoff
|
||||
FROM pos_orders WHERE %s`, where), params...).Scan(&head).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
summary.Billcount = head.Billcount
|
||||
summary.Itemcount = head.Itemcount
|
||||
summary.Grosssales = head.Grosssales
|
||||
summary.Taxcollected = head.Taxcollected
|
||||
summary.Discount = head.Discount
|
||||
summary.Roundoff = head.Roundoff
|
||||
if head.Billcount > 0 {
|
||||
summary.Averagebill = head.Grosssales / float64(head.Billcount)
|
||||
}
|
||||
|
||||
if err := r.db.Raw(fmt.Sprintf(`
|
||||
SELECT COALESCE(paymentmode,'') AS paymentmode,
|
||||
COUNT(*) AS billcount, COALESCE(SUM(total),0) AS amount
|
||||
FROM pos_orders WHERE %s
|
||||
GROUP BY paymentmode ORDER BY amount DESC`, where),
|
||||
params...).Scan(&summary.Bypaymentmode).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.db.Raw(fmt.Sprintf(`
|
||||
SELECT businessdate, COUNT(*) AS billcount,
|
||||
COALESCE(SUM(total),0) AS amount
|
||||
FROM pos_orders WHERE %s
|
||||
GROUP BY businessdate ORDER BY businessdate`, where),
|
||||
params...).Scan(&summary.Byday).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.db.Raw(fmt.Sprintf(`
|
||||
SELECT COALESCE(terminalid,'') AS terminalid, COUNT(*) AS billcount,
|
||||
COALESCE(SUM(total),0) AS amount
|
||||
FROM pos_orders WHERE %s
|
||||
GROUP BY terminalid ORDER BY amount DESC`, where),
|
||||
params...).Scan(&summary.Byterminal).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
@@ -465,7 +465,13 @@ func (r *productRepository) GetLocationProducts(tenantID, locationID, subcategor
|
||||
// into Locationproducts.Quantity, which is what made the console's stock
|
||||
// column look frozen after an order despite CreateOrder recording the
|
||||
// "out" ledger entry correctly.
|
||||
query := `SELECT a.*, b.productlocationid, b.status, b.price,
|
||||
// COALESCE so `price` means the same thing here as in GetProducts: the
|
||||
// effective selling price at this outlet, falling back to the master
|
||||
// retailprice when the store hasn't set its own. Returning a bare b.price
|
||||
// reported 0 for any product priced only at tenant level, which the store
|
||||
// catalogue then rendered as "—".
|
||||
query := `SELECT a.*, b.productlocationid, b.status,
|
||||
COALESCE(NULLIF(b.price, 0), a.retailprice, 0) AS price,
|
||||
COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'IN' THEN c.quantity ELSE 0 END), 0) AS total_in,
|
||||
COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'OUT' THEN c.quantity ELSE 0 END), 0) AS total_out,
|
||||
COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'IN' THEN c.quantity ELSE 0 END) -
|
||||
@@ -842,7 +848,7 @@ func (r *productRepository) GetProducts(params models.ProductFilter) ([]models.P
|
||||
var products []models.Products
|
||||
|
||||
q := r.db.Table("products a").
|
||||
Joins("LEFT JOIN productlocations pl ON pl.productid = a.productid").
|
||||
Joins("LEFT JOIN productlocations pl ON pl.productid = a.productid AND pl.tenantid = a.tenantid").
|
||||
Joins("LEFT JOIN productdiscounts pd ON pd.productid = a.productid").
|
||||
Joins("LEFT JOIN productcategories c ON a.categoryid = c.categoryid").
|
||||
Where("a.categoryid = ?", params.CategoryID)
|
||||
@@ -875,9 +881,26 @@ func (r *productRepository) GetProducts(params models.ProductFilter) ([]models.P
|
||||
// never-decremented products.quantity column — same fix as
|
||||
// GetLocationProducts/GetProductByVariant, otherwise this endpoint would
|
||||
// keep showing stock that never reduces after an order.
|
||||
// price is the effective selling price at params.LocationID: the store's own
|
||||
// productlocations.price, falling back to the master products.retailprice
|
||||
// when that outlet hasn't set one. It has to be here — this endpoint feeds
|
||||
// the customer app's browse-by-subcategory view, and `a.*` only carries
|
||||
// retailprice, which the admin catalogue never writes. So a price the admin
|
||||
// set per store could never reach the app; every product priced as 0.
|
||||
//
|
||||
// Deliberately a correlated subquery rather than a read off the joined `pl`:
|
||||
// that join isn't outlet-scoped unless params.LocationID is set, so reading
|
||||
// pl.price directly would pick an arbitrary branch's price (and multiply the
|
||||
// rows) whenever the caller didn't scope to one. Same shape as the
|
||||
// productstock subqueries below, for the same reason.
|
||||
err := q.Select(`
|
||||
a.*,
|
||||
COALESCE(pd.discountvalue, 0) AS discountvalue,
|
||||
COALESCE(NULLIF((
|
||||
SELECT pl2.price FROM productlocations pl2
|
||||
WHERE pl2.productid = a.productid AND pl2.tenantid = a.tenantid AND pl2.locationid = ?
|
||||
LIMIT 1
|
||||
), 0), a.retailprice, 0) AS price,
|
||||
COALESCE((
|
||||
SELECT SUM(CASE WHEN LOWER(ps.stocktype) = 'in' THEN ps.quantity ELSE 0 END) -
|
||||
SUM(CASE WHEN LOWER(ps.stocktype) = 'out' THEN ps.quantity ELSE 0 END)
|
||||
@@ -890,7 +913,7 @@ func (r *productRepository) GetProducts(params models.ProductFilter) ([]models.P
|
||||
FROM productstocks ps
|
||||
WHERE ps.productid = a.productid AND ps.tenantid = a.tenantid AND ps.locationid = ?
|
||||
), 0) AS quantity
|
||||
`, params.LocationID, params.LocationID).Find(&products).Error
|
||||
`, params.LocationID, params.LocationID, params.LocationID).Find(&products).Error
|
||||
|
||||
return products, err
|
||||
}
|
||||
|
||||
@@ -23,6 +23,17 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
|
||||
pos.Post("/customers", f.PosController.IngestCustomers)
|
||||
pos.Get("/catalogue", f.PosController.Catalogue)
|
||||
|
||||
// The 30-second heartbeat, for tills on the HTTP route. The broker carries
|
||||
// the same payload for tills on MQTT; both land in the same Redis record,
|
||||
// so the fleet board cannot tell them apart and does not need to.
|
||||
pos.Post("/health", f.PosController.IngestHealth)
|
||||
|
||||
// Counter sales, read back out. The ingest above only ever writes; without
|
||||
// these a committed bill is unreachable from every screen in the product.
|
||||
pos.Get("/sales", f.PosController.GetSales)
|
||||
pos.Get("/sales/detail", f.PosController.GetSaleDetail)
|
||||
pos.Get("/sales/summary", f.PosController.GetSalesSummary)
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -15,7 +15,8 @@ import (
|
||||
// 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"
|
||||
// Every mobile the probes registered, so a cleanup run leaves nothing behind.
|
||||
var probeMobiles = []string{"9840012345", "9840099999", "9840077777"}
|
||||
|
||||
var testOrderIDs = []string{
|
||||
"11111111-2222-4333-8444-555555555555", // the HTTP probe
|
||||
@@ -70,16 +71,21 @@ func cleanup(db *gorm.DB) {
|
||||
|
||||
// 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)
|
||||
for _, mobile := range probeMobiles {
|
||||
// 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 IN
|
||||
(SELECT customerid FROM customers WHERE contactno = ?)`,
|
||||
mobile).Scan(&referenced)
|
||||
if referenced > 0 {
|
||||
fmt.Printf(" customer %s left in place — %d order(s) reference it\n",
|
||||
mobile, referenced)
|
||||
continue
|
||||
}
|
||||
res := db.Exec(`DELETE FROM customers WHERE contactno = ?`, mobile)
|
||||
if res.RowsAffected > 0 {
|
||||
fmt.Printf(" removed probe customer %s\n", mobile)
|
||||
}
|
||||
}
|
||||
|
||||
var balance float64
|
||||
|
||||
@@ -153,6 +153,26 @@ func main() {
|
||||
case "cleanup":
|
||||
cleanup(db)
|
||||
|
||||
case "columns":
|
||||
for _, t := range []string{"products", "productlocations", "productstocks"} {
|
||||
fmt.Printf("=== %s ===\n", t)
|
||||
var cols []struct {
|
||||
ColumnName string
|
||||
DataType string
|
||||
}
|
||||
db.Raw(`SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_name = ? ORDER BY ordinal_position`, t).Scan(&cols)
|
||||
for _, c := range cols {
|
||||
marker := ""
|
||||
n := c.ColumnName
|
||||
if n == "created" || n == "updated" || n == "updated_at" ||
|
||||
n == "stockdate" || n == "modified" {
|
||||
marker = " <-- timestamp"
|
||||
}
|
||||
fmt.Printf(" %-24s %s%s\n", c.ColumnName, c.DataType, marker)
|
||||
}
|
||||
}
|
||||
|
||||
case "customer":
|
||||
fmt.Println("=== customers matching the uplink probe ===")
|
||||
showCustomer(db, "9840012345")
|
||||
|
||||
180
scratch/gstrates/main.go
Normal file
180
scratch/gstrates/main.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// Set GST rates on the POS catalogue products.
|
||||
//
|
||||
// The four packaged lines at 1185 sit at taxpercent 0 and are being billed with
|
||||
// no GST at all — a live compliance problem rather than a cosmetic one. Those
|
||||
// are written.
|
||||
//
|
||||
// The produce at 1135 is NOT all zero, which is what this was first written
|
||||
// believing. It holds 8, 12 and 18, and under Indian GST fresh unbranded fruit
|
||||
// and chilled fish are nil-rated — so several look like overcharging. Every
|
||||
// correction there is a *reduction* of a live rate, which is a decision for
|
||||
// whoever signs the returns. Reported as REVIEW and left untouched.
|
||||
//
|
||||
// go run ./scratch/gstrates plan
|
||||
// go run ./scratch/gstrates apply
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// Indian GST on food, as it applies to these lines.
|
||||
//
|
||||
// Fresh, unbranded and unpackaged produce is nil-rated, which is why the fruit
|
||||
// stays at 0 rather than being "not set yet". Packaged branded snacks are 12%.
|
||||
// Breakfast cereal is 18%.
|
||||
//
|
||||
// Fish is the one worth stating: fresh or chilled is nil-rated, and only
|
||||
// frozen/branded/packaged attracts 5%. Left at 0 on the reading that a counter
|
||||
// selling loose Mysore bananas is selling fresh fish, not frozen packs.
|
||||
type rate struct {
|
||||
productID int
|
||||
name string
|
||||
percent float64
|
||||
why string
|
||||
|
||||
// apply gates the write. Only rows that are unambiguously *unset* are
|
||||
// written; anything already carrying a rate is reported and left alone.
|
||||
//
|
||||
// The fresh produce at 1135 is the reason for this flag. Those rows are not
|
||||
// blank — they hold 8, 12 and 18 — and under Indian GST fresh unbranded
|
||||
// fruit and chilled fish are nil-rated, so several look like overcharging.
|
||||
// But *lowering* a live tax rate is a compliance decision belonging to
|
||||
// whoever signs the returns, not a bug to be quietly corrected by a script,
|
||||
// and someone is actively working on pricing in this repo. Reported, not
|
||||
// touched.
|
||||
apply bool
|
||||
}
|
||||
|
||||
var rates = []rate{
|
||||
// 1135 — fresh produce, nil-rated under Indian GST.
|
||||
//
|
||||
// These were held back at first because every one is a *reduction* of a
|
||||
// live rate, which is a compliance decision rather than a bug fix. Released
|
||||
// on the owner's explicit instruction after that was put to them.
|
||||
//
|
||||
// Two readings are assumed and should be checked against what the counter
|
||||
// actually sells: Maceral and Tuna are taken as fresh or chilled, which is
|
||||
// nil-rated — frozen, branded or packaged fish is 5%. Hatsun curd is taken
|
||||
// as plain curd, which is nil-rated — flavoured yoghurt is 5%.
|
||||
{6988, "Mysore Banana", 0, "fresh fruit — nil-rated, currently 8%", true},
|
||||
{6989, "Jammu Apple", 0, "fresh fruit — nil-rated, currently 18%", true},
|
||||
{6990, "Small orange", 0, "fresh fruit — nil-rated, currently 18%", true},
|
||||
{6991, "Red Guava", 0, "fresh fruit — nil-rated, currently 18%", true},
|
||||
{6992, "Pomegrante", 0, "fresh fruit — nil-rated, currently 12%", true},
|
||||
{6993, "Salem Mango", 0, "fresh fruit — nil-rated", true},
|
||||
{6994, "Pineapple", 0, "fresh fruit — nil-rated", true},
|
||||
{6995, "Strawberries", 0, "fresh fruit — nil-rated, currently 18%", true},
|
||||
{6996, "Maceral", 0, "fresh fish nil-rated; 5% only if frozen/packaged", true},
|
||||
{6997, "Tuna", 0, "fresh fish nil-rated; 5% only if frozen/packaged", true},
|
||||
{6998, "Hatsun curd", 0, "curd nil-rated; flavoured yoghurt would be 5%", true},
|
||||
{7014, "Apple", 0, "fresh fruit — nil-rated", true},
|
||||
|
||||
// 1185 — genuinely unset, and being billed with no GST at all today. This
|
||||
// is the half that is unambiguous: every one is an increase from zero, so
|
||||
// nothing is being under-collected on the strength of a script's opinion.
|
||||
{7074, "Amla Dabur Oral Care Chewing Gum 10g", 18, "chewing gum, 18%", true},
|
||||
{7075, "Cheetos Chips 100g", 12, "packaged extruded snack, 12%", true},
|
||||
{7076, "Cheerios Breakfast Cereal 100g", 18, "packaged cereal, 18%", true},
|
||||
{7077, "Hot Heads 30g", 12, "packaged snack, 12%", true},
|
||||
}
|
||||
|
||||
func main() {
|
||||
mode := "plan"
|
||||
if len(os.Args) > 1 {
|
||||
mode = os.Args[1]
|
||||
}
|
||||
|
||||
_ = godotenv.Load()
|
||||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
|
||||
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
write := mode == "apply"
|
||||
|
||||
fmt.Printf("%-6s %-38s %6s -> %6s %s\n", "id", "product", "now", "new", "why")
|
||||
fmt.Println("-------------------------------------------------------------------------------------------")
|
||||
|
||||
undo := []string{}
|
||||
changes := 0
|
||||
review := 0
|
||||
|
||||
for _, r := range rates {
|
||||
var current struct {
|
||||
Taxpercent float64
|
||||
Found bool
|
||||
}
|
||||
if err := db.Raw(`SELECT COALESCE(taxpercent, 0) AS taxpercent, true AS found
|
||||
FROM products WHERE productid = ? LIMIT 1`,
|
||||
r.productID).Scan(¤t).Error; err != nil {
|
||||
log.Fatalf("reading %d: %v", r.productID, err)
|
||||
}
|
||||
if !current.Found {
|
||||
fmt.Printf("%-6d %-38s NO products ROW - skipped\n", r.productID, r.name)
|
||||
continue
|
||||
}
|
||||
|
||||
if current.Taxpercent == r.percent {
|
||||
fmt.Printf("%-6d %-38s %6.0f unchanged %s\n",
|
||||
r.productID, r.name, current.Taxpercent, r.why)
|
||||
continue
|
||||
}
|
||||
|
||||
if !r.apply {
|
||||
fmt.Printf("%-6d %-38s %6.0f REVIEW %-3.0f %s\n",
|
||||
r.productID, r.name, current.Taxpercent, r.percent, r.why)
|
||||
review++
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("%-6d %-38s %6.0f -> %6.0f %s\n",
|
||||
r.productID, r.name, current.Taxpercent, r.percent, r.why)
|
||||
|
||||
undo = append(undo, fmt.Sprintf(
|
||||
"UPDATE products SET taxpercent = %.0f WHERE productid = %d;",
|
||||
current.Taxpercent, r.productID))
|
||||
changes++
|
||||
|
||||
if write {
|
||||
// updated is bumped so the catalogue delta carries the new rate to
|
||||
// terminals holding a revision, rather than waiting for a full pull.
|
||||
if err := db.Exec(`UPDATE products SET taxpercent = ?, updated = NOW()
|
||||
WHERE productid = ?`, r.percent, r.productID).Error; err != nil {
|
||||
log.Fatalf("writing %d: %v", r.productID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("-------------------------------------------------------------------------------------------")
|
||||
if write {
|
||||
fmt.Printf("APPLIED %d rate(s).\n", changes)
|
||||
} else {
|
||||
fmt.Printf("%d rate(s) would change. Nothing written — run `apply` to commit.\n", changes)
|
||||
}
|
||||
if review > 0 {
|
||||
fmt.Printf("%d row(s) flagged REVIEW and deliberately not written — each is a\n"+
|
||||
"reduction of a live tax rate and needs a decision, not a script.\n", review)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if len(undo) > 0 {
|
||||
fmt.Println("-- undo:")
|
||||
for _, u := range undo {
|
||||
fmt.Println(u)
|
||||
}
|
||||
}
|
||||
}
|
||||
141
scratch/healthproof/main.go
Normal file
141
scratch/healthproof/main.go
Normal file
@@ -0,0 +1,141 @@
|
||||
// Proof that the 30-second health heartbeat works end to end, over MQTT.
|
||||
//
|
||||
// Publishes a heartbeat to the live broker as a throwaway terminal, then polls
|
||||
// the public API until it shows online — and keeps polling past the TTL so the
|
||||
// automatic expiry is visible too. Nothing is written to Postgres; presence
|
||||
// lives in Redis under a TTL and cleans itself up.
|
||||
//
|
||||
// REQUIRES pos_terminal CREDENTIALS. The MQTT_USER in .env is pos_ingest, which
|
||||
// the broker ACL deliberately denies publish on the health topic — it may only
|
||||
// write acks and the catalogue. Running this with the ingest account connects
|
||||
// fine and then silently drops every publish, because Mosquitto answers an
|
||||
// ACL-denied QoS 1 publish with a PUBACK and discards it. That looks exactly
|
||||
// like a broken consumer and cost an hour of misdiagnosis; set MQTT_USER and
|
||||
// MQTT_PASSWORD to the terminal account before believing a negative result.
|
||||
//
|
||||
// The HTTP path needs none of this — see POST /pos/health, which is what the
|
||||
// fix on v1.3.96 added and how the endpoint was actually verified.
|
||||
//
|
||||
// go run ./scratch/healthproof
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
const (
|
||||
locationID = "1135"
|
||||
terminalID = "TPROOF" // throwaway; disappears on its own when the TTL lapses
|
||||
apiBase = "https://fiesta.nearle.app/live/api/v1/pos"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
brokerURL := os.Getenv("MQTT_URL")
|
||||
if brokerURL == "" {
|
||||
log.Fatal("MQTT_URL not set")
|
||||
}
|
||||
|
||||
opts := mqtt.NewClientOptions().
|
||||
AddBroker(brokerURL).
|
||||
SetClientID("healthproof-" + terminalID).
|
||||
SetUsername(os.Getenv("MQTT_USER")).
|
||||
SetPassword(os.Getenv("MQTT_PASSWORD")).
|
||||
SetConnectTimeout(10 * time.Second)
|
||||
|
||||
client := mqtt.NewClient(opts)
|
||||
if t := client.Connect(); t.Wait() && t.Error() != nil {
|
||||
log.Fatal("connect: ", t.Error())
|
||||
}
|
||||
defer client.Disconnect(250)
|
||||
fmt.Printf("connected to %s as %s\n\n", brokerURL, terminalID)
|
||||
|
||||
fmt.Println("BEFORE — has this terminal ever been seen?")
|
||||
show()
|
||||
|
||||
// Exactly what the till sends every 30 seconds.
|
||||
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": 17, "today_amount": 2510.0,
|
||||
"printer_reachable": true,
|
||||
"reported_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
|
||||
topic := fmt.Sprintf("nearle/pos/%s/%s/health", locationID, terminalID)
|
||||
if t := client.Publish(topic, 1, false, health); t.Wait() && t.Error() != nil {
|
||||
log.Fatal("publish: ", t.Error())
|
||||
}
|
||||
fmt.Printf("\npublished one heartbeat to %s\n", topic)
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
fmt.Println("\nAFTER one heartbeat:")
|
||||
show()
|
||||
|
||||
// The TTL is 90s and a real till refreshes every 30s, so it never lapses
|
||||
// while the till is alive. Stopping here is what a till being switched off
|
||||
// looks like.
|
||||
fmt.Println("\nnow going quiet, as a till that was switched off would.")
|
||||
fmt.Println("presence TTL is 90s, so it should drop off on its own:")
|
||||
|
||||
for _, wait := range []int{30, 30, 35} {
|
||||
time.Sleep(time.Duration(wait) * time.Second)
|
||||
fmt.Printf("\n+%ds since the last heartbeat:\n", wait)
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
func show() {
|
||||
resp, err := http.Get(fmt.Sprintf("%s/health/location?location_id=%s", apiBase, locationID))
|
||||
if err != nil {
|
||||
fmt.Println(" API unreachable:", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var out struct {
|
||||
Details struct {
|
||||
Online int `json:"online"`
|
||||
Total int `json:"total"`
|
||||
Terminals []struct {
|
||||
Terminalid string `json:"terminal_id"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
Todaybills int `json:"today_bills"`
|
||||
Todayamount float64 `json:"today_amount"`
|
||||
Reportedat string `json:"reported_at"`
|
||||
} `json:"terminals"`
|
||||
} `json:"details"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
fmt.Println(" unparseable:", string(body)[:200])
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" online %d of %d\n", out.Details.Online, out.Details.Total)
|
||||
for _, t := range out.Details.Terminals {
|
||||
mark := " "
|
||||
if t.Terminalid == terminalID {
|
||||
mark = ">"
|
||||
}
|
||||
extra := t.Reason
|
||||
if t.Status == "online" {
|
||||
extra = fmt.Sprintf("today %d bills / Rs %.0f, reported %s",
|
||||
t.Todaybills, t.Todayamount, t.Reportedat)
|
||||
}
|
||||
fmt.Printf(" %s %-8s %-8s %s\n", mark, t.Terminalid, t.Status, extra)
|
||||
}
|
||||
}
|
||||
@@ -118,5 +118,31 @@ func main() {
|
||||
log.Fatal("publish health:", t.Error())
|
||||
}
|
||||
fmt.Println("\npublished a heartbeat to", healthTopic)
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// The registration uplink. Tested over HTTP early on; this is the same
|
||||
// service reached over the broker, which is the path a real till uses.
|
||||
custBatch, _ := json.Marshal(map[string]any{
|
||||
"schema": 1, "batch_id": "batch-cust-mqtt-0001",
|
||||
"store_id": locationID, "terminal_id": terminalID,
|
||||
"customers": []map[string]any{{
|
||||
"id": "3d7a0000-0000-4000-8000-000000000001",
|
||||
"mobile": "9840077777",
|
||||
"name": "MQTT Probe Shopper",
|
||||
"registered_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"registered_by_terminal": terminalID,
|
||||
}},
|
||||
})
|
||||
custTopic := fmt.Sprintf("nearle/pos/%s/%s/customer", locationID, terminalID)
|
||||
if t := client.Publish(custTopic, 1, false, custBatch); t.Wait() && t.Error() != nil {
|
||||
log.Fatal("publish customer:", t.Error())
|
||||
}
|
||||
fmt.Println("published a registration to", custTopic)
|
||||
|
||||
select {
|
||||
case payload := <-acks:
|
||||
fmt.Println(" registration ACK:", string(payload))
|
||||
case <-time.After(20 * time.Second):
|
||||
fmt.Println(" NO ACK for the registration")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
194
scratch/seedprices/main.go
Normal file
194
scratch/seedprices/main.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Seed retail prices so the POS has something sellable.
|
||||
//
|
||||
// These are plausible Coimbatore figures, not authoritative ones. They exist so
|
||||
// the terminal can ring a real bill; the owner corrects them afterwards.
|
||||
//
|
||||
// go run ./scratch/seedprices plan # show every change and the undo SQL
|
||||
// go run ./scratch/seedprices apply # write them
|
||||
// go run ./scratch/seedprices verify # read back what the catalogue now serves
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type priced struct {
|
||||
locationID int
|
||||
productID int
|
||||
name string
|
||||
unit string
|
||||
price float64
|
||||
note string
|
||||
}
|
||||
|
||||
// Prices are per the product's own unit — per kilogram where the unit is
|
||||
// kilogram, per pack where it is piece. Getting that backwards is the easiest
|
||||
// way to make a till look broken, so the unit is carried through and printed.
|
||||
var seed = []priced{
|
||||
// 1135 — fresh produce
|
||||
{1135, 6988, "Mysore Banana", "kilogram", 60, ""},
|
||||
{1135, 6989, "Jammu Apple", "piece", 30, "per fruit, not per kg"},
|
||||
{1135, 6990, "Small orange", "kilogram", 90, ""},
|
||||
{1135, 6991, "Red Guava", "kilogram", 80, ""},
|
||||
{1135, 6992, "Pomegrante", "kilogram", 180, ""},
|
||||
{1135, 6993, "Salem Mango", "kilogram", 90, "seasonal, swings 80-120"},
|
||||
{1135, 6994, "Pineapple", "kilogram", 60, ""},
|
||||
{1135, 6995, "Strawberries", "piece", 150, "priced as a punnet"},
|
||||
{1135, 6996, "Maceral", "kilogram", 220, "READ AS MACKEREL - correct if wrong"},
|
||||
{1135, 6997, "Tuna", "kilogram", 280, ""},
|
||||
{1135, 6998, "Hatsun curd", "piece", 30, "500g pouch"},
|
||||
{1135, 7014, "Apple", "kilogram", 200, ""},
|
||||
|
||||
// 1185 — packaged
|
||||
{1185, 7074, "Amla Dabur Oral Care Chewing Gum 10g", "piece", 10, ""},
|
||||
{1185, 7075, "Cheetos Chips 100g", "piece", 40, ""},
|
||||
{1185, 7076, "Cheerios Breakfast Cereal 100g", "piece", 120, ""},
|
||||
// 7077 Hot Heads is already at 50 — someone set it deliberately, leave it.
|
||||
}
|
||||
|
||||
func main() {
|
||||
mode := "plan"
|
||||
if len(os.Args) > 1 {
|
||||
mode = os.Args[1]
|
||||
}
|
||||
|
||||
_ = godotenv.Load()
|
||||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
|
||||
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "plan":
|
||||
plan(db, false)
|
||||
case "apply":
|
||||
plan(db, true)
|
||||
case "verify":
|
||||
verify(db)
|
||||
default:
|
||||
log.Fatalf("unknown mode %q — use plan, apply or verify", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func plan(db *gorm.DB, write bool) {
|
||||
fmt.Printf("%-6s %-38s %-9s %8s -> %8s\n", "id", "product", "unit", "now", "new")
|
||||
fmt.Println("--------------------------------------------------------------------------------")
|
||||
|
||||
undo := []string{}
|
||||
changes := 0
|
||||
|
||||
for _, p := range seed {
|
||||
var current struct {
|
||||
Price float64
|
||||
Tenantid int
|
||||
Found bool
|
||||
}
|
||||
row := db.Raw(`SELECT COALESCE(price, 0) AS price, tenantid, true AS found
|
||||
FROM productlocations
|
||||
WHERE productid = ? AND locationid = ?
|
||||
LIMIT 1`, p.productID, p.locationID).Scan(¤t)
|
||||
if row.Error != nil {
|
||||
log.Fatalf("reading %d: %v", p.productID, row.Error)
|
||||
}
|
||||
if !current.Found {
|
||||
fmt.Printf("%-6d %-38s NO productlocations ROW - skipped\n", p.productID, p.name)
|
||||
continue
|
||||
}
|
||||
|
||||
// Never overwrite a price a human already set. A seed value is a
|
||||
// placeholder; a real one is a decision, and losing it silently would
|
||||
// be worse than leaving a gap.
|
||||
if current.Price > 0 {
|
||||
fmt.Printf("%-6d %-38s %-9s %8.2f already priced, left alone\n",
|
||||
p.productID, p.name, p.unit, current.Price)
|
||||
continue
|
||||
}
|
||||
|
||||
note := ""
|
||||
if p.note != "" {
|
||||
note = " <- " + p.note
|
||||
}
|
||||
fmt.Printf("%-6d %-38s %-9s %8.2f -> %8.2f%s\n",
|
||||
p.productID, p.name, p.unit, current.Price, p.price, note)
|
||||
|
||||
undo = append(undo, fmt.Sprintf(
|
||||
"UPDATE productlocations SET price = %.2f WHERE productid = %d AND locationid = %d;",
|
||||
current.Price, p.productID, p.locationID))
|
||||
changes++
|
||||
|
||||
if write {
|
||||
// updated is bumped so the catalogue delta carries the new price to
|
||||
// terminals that already hold a revision, rather than waiting for
|
||||
// someone to force a full pull.
|
||||
err := db.Exec(`UPDATE productlocations
|
||||
SET price = ?, updated = NOW()
|
||||
WHERE productid = ? AND locationid = ?`,
|
||||
p.price, p.productID, p.locationID).Error
|
||||
if err != nil {
|
||||
log.Fatalf("writing %d: %v", p.productID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("--------------------------------------------------------------------------------")
|
||||
if write {
|
||||
fmt.Printf("APPLIED %d price(s).\n\n", changes)
|
||||
} else {
|
||||
fmt.Printf("%d price(s) would change. Nothing written — run `apply` to commit.\n\n", changes)
|
||||
}
|
||||
|
||||
fmt.Println("-- undo, if you want the zeros back:")
|
||||
for _, u := range undo {
|
||||
fmt.Println(u)
|
||||
}
|
||||
}
|
||||
|
||||
func verify(db *gorm.DB) {
|
||||
type row struct {
|
||||
Locationid int
|
||||
Productid int
|
||||
Productname string
|
||||
Price float64
|
||||
Taxpercent float64
|
||||
}
|
||||
var rows []row
|
||||
db.Raw(`SELECT b.locationid, a.productid, a.productname,
|
||||
COALESCE(b.price, 0) AS price, COALESCE(a.taxpercent, 0) AS taxpercent
|
||||
FROM products a
|
||||
INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid
|
||||
WHERE b.locationid IN (1135, 1185) AND a.productid > 0
|
||||
ORDER BY b.locationid, a.productid`).Scan(&rows)
|
||||
|
||||
sellable := 0
|
||||
for _, r := range rows {
|
||||
flag := ""
|
||||
if r.Price > 0 {
|
||||
sellable++
|
||||
} else {
|
||||
flag = " <- still zero, not sellable"
|
||||
}
|
||||
fmt.Printf("loc %d %-6d %-38s %8.2f tax=%.0f%s\n",
|
||||
r.Locationid, r.Productid, r.Productname[:min(38, len(r.Productname))], r.Price, r.Taxpercent, flag)
|
||||
}
|
||||
fmt.Printf("\n%d of %d rows are sellable.\n", sellable, len(rows))
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
90
scratch/termfixcleanup/main.go
Normal file
90
scratch/termfixcleanup/main.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Removes the single bill posted to prove the terminalid fix on v1.3.96.
|
||||
//
|
||||
// Named by its own terminalorderid rather than by date or by "the newest row" —
|
||||
// pos_orders holds real takings, and is not a table to run an unbounded DELETE
|
||||
// against. Stock is returned before the bill is deleted, so the ledger is never
|
||||
// left short with nothing remaining to explain why.
|
||||
//
|
||||
// go run ./scratch/termfixcleanup
|
||||
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
|
||||
testOrder = "a1b2c3d4-0000-4000-8000-termfix00001"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
|
||||
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var billIDs []int
|
||||
db.Raw(`SELECT posorderid FROM pos_orders WHERE terminalorderid = ?`,
|
||||
testOrder).Scan(&billIDs)
|
||||
|
||||
if len(billIDs) == 0 {
|
||||
fmt.Println("no test bill found — nothing to undo")
|
||||
return
|
||||
}
|
||||
fmt.Printf("found test bill(s): %v\n", billIDs)
|
||||
|
||||
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 rounds 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 unit(s) of product %d\n", qty, c.Productid)
|
||||
}
|
||||
|
||||
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 bill(s) and their items\n", len(billIDs))
|
||||
|
||||
var left int64
|
||||
db.Raw(`SELECT COUNT(*) FROM pos_orders WHERE terminalorderid = ?`, testOrder).Scan(&left)
|
||||
fmt.Printf("\nremaining test rows: %d\n", left)
|
||||
|
||||
var stock 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 productid = 6988 AND locationid = ? AND tenantid = ?`,
|
||||
locationID, tenantID).Scan(&stock)
|
||||
fmt.Printf("Mysore Banana stock now: %.0f (was 750 before any probe)\n", stock)
|
||||
}
|
||||
@@ -19,6 +19,10 @@ type PosService interface {
|
||||
|
||||
TerminalHealth(ctx context.Context, terminalID string) (map[string]string, error)
|
||||
LocationHealth(ctx context.Context, locationID string) ([]map[string]string, error)
|
||||
|
||||
Sales(f models.PosSalesFilter) (*models.PosSalesPage, error)
|
||||
SaleDetail(locationID int, reference string) (*models.PosOrders, error)
|
||||
SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error)
|
||||
}
|
||||
|
||||
type posService struct {
|
||||
@@ -53,3 +57,15 @@ func (s *posService) IngestCustomers(batch models.PosCustomerBatch) (*models.Pos
|
||||
func (s *posService) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) {
|
||||
return s.repo.Catalogue(storeID, since, page, pageSize)
|
||||
}
|
||||
|
||||
func (s *posService) Sales(f models.PosSalesFilter) (*models.PosSalesPage, error) {
|
||||
return s.repo.Sales(f)
|
||||
}
|
||||
|
||||
func (s *posService) SaleDetail(locationID int, reference string) (*models.PosOrders, error) {
|
||||
return s.repo.SaleDetail(locationID, reference)
|
||||
}
|
||||
|
||||
func (s *posService) SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error) {
|
||||
return s.repo.SalesSummary(f)
|
||||
}
|
||||
|
||||
@@ -293,6 +293,11 @@ func (s *productService) ImportCatalogueProduct(reqs []models.ImportCataloguePro
|
||||
}
|
||||
}
|
||||
|
||||
// Price carries the selling price onto the per-store row. Omitting it
|
||||
// left productlocations.price at 0 for every imported product, and that
|
||||
// column — not products.retailprice — is what the store catalogue, the
|
||||
// customer app and each order line read. The result was a catalogue
|
||||
// where nothing had a price and every order booked an amount of 0.
|
||||
locations = append(locations, models.Productlocations{
|
||||
Tenantid: req.Tenantid,
|
||||
Locationid: req.Locationid,
|
||||
@@ -300,6 +305,7 @@ func (s *productService) ImportCatalogueProduct(reqs []models.ImportCataloguePro
|
||||
Quantity: req.Quantity,
|
||||
Stocktype: req.Stocktype,
|
||||
Status: req.Status,
|
||||
Price: float32(req.Retailprice),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user