Compare commits
7 Commits
76bff883ec
...
feat/env-l
| Author | SHA1 | Date | |
|---|---|---|---|
| 24339a8b51 | |||
| 01bc89ab77 | |||
| 692c10e553 | |||
| 2f1501883b | |||
| c06b029cb2 | |||
| 28af3e05f2 | |||
| 42ea007fe7 |
@@ -120,8 +120,12 @@ current example of all seven.
|
|||||||
- **One MQTT client id per replica.** A second connection with the same id
|
- **One MQTT client id per replica.** A second connection with the same id
|
||||||
evicts the first. Never run a local process with the production
|
evicts the first. Never run a local process with the production
|
||||||
`MQTT_URL`.
|
`MQTT_URL`.
|
||||||
- **`scratch/` tools read production** when run with `.env.production`.
|
- **`scratch/` tools read production** when run with `.env.production`, and
|
||||||
They are read-only by construction; keep them that way.
|
most are read-only. Two are not — `termbackfill` and
|
||||||
|
`cataloguefactsbackfill` repair rows that no endpoint can reach. Both
|
||||||
|
default to a dry run that prints every change and write only when passed
|
||||||
|
`apply`, and both print the SQL to undo themselves afterwards. A new tool
|
||||||
|
that writes follows that shape or it does not write.
|
||||||
|
|
||||||
## Operations cheat-sheet (Kubernetes, namespace `nearle`)
|
## Operations cheat-sheet (Kubernetes, namespace `nearle`)
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,14 @@ func (ctl *ProductController) CreateProduct(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ctl.productService.CreateProduct(product); err != nil {
|
// The created row, not the parsed body.
|
||||||
|
//
|
||||||
|
// This returned the struct it had just parsed off the request, which by
|
||||||
|
// definition carried `productid: 0` — the id is assigned by the database a
|
||||||
|
// moment later and was never read back. Every caller that needed the id
|
||||||
|
// went and looked the product up again by SKU.
|
||||||
|
created, err := ctl.productService.CreateProduct(product)
|
||||||
|
if err != nil {
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"code": http.StatusInternalServerError,
|
"code": http.StatusInternalServerError,
|
||||||
"message": "Failed to create product",
|
"message": "Failed to create product",
|
||||||
@@ -203,7 +210,7 @@ func (ctl *ProductController) CreateProduct(c *fiber.Ctx) error {
|
|||||||
"code": http.StatusCreated,
|
"code": http.StatusCreated,
|
||||||
"message": "Product created successfully",
|
"message": "Product created successfully",
|
||||||
"status": true,
|
"status": true,
|
||||||
"data": product,
|
"data": created,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,25 @@ func (ctl *TenantController) SearchTenant(c *fiber.Ctx) error {
|
|||||||
func (ctl *TenantController) GetAllTenants(c *fiber.Ctx) error {
|
func (ctl *TenantController) GetAllTenants(c *fiber.Ctx) error {
|
||||||
pageno, _ := strconv.Atoi(c.Query("pageno"))
|
pageno, _ := strconv.Atoi(c.Query("pageno"))
|
||||||
pagesize, _ := strconv.Atoi(c.Query("pagesize"))
|
pagesize, _ := strconv.Atoi(c.Query("pagesize"))
|
||||||
|
|
||||||
|
// Paging is defaulted, not required.
|
||||||
|
//
|
||||||
|
// The repository builds LIMIT/OFFSET from these directly, so a caller that
|
||||||
|
// omitted either — or sent pageno=0 — got an empty result reported as
|
||||||
|
// `code 200, status true, message "Success"`. "There are no tenants on the
|
||||||
|
// platform" and "you forgot a query parameter" are very different answers
|
||||||
|
// and this endpoint gave the first for the second.
|
||||||
|
//
|
||||||
|
// Defaulted rather than rejected with a 400: every existing caller that
|
||||||
|
// works today keeps working, and a platform list with no paging asked for
|
||||||
|
// has an obvious right answer — the first page.
|
||||||
|
if pageno < 1 {
|
||||||
|
pageno = 1
|
||||||
|
}
|
||||||
|
if pagesize < 1 {
|
||||||
|
pagesize = 50
|
||||||
|
}
|
||||||
|
|
||||||
status := c.Query("status")
|
status := c.Query("status")
|
||||||
aid, _ := strconv.Atoi(c.Query("applocationid"))
|
aid, _ := strconv.Atoi(c.Query("applocationid"))
|
||||||
tenanttype := c.Query("tenanttype")
|
tenanttype := c.Query("tenanttype")
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ recommend. When the customer taps a store and a size, a second call confirms
|
|||||||
the shelf still has it — and if it does not, names the next-nearest store
|
the shelf still has it — and if it does not, names the next-nearest store
|
||||||
that does.
|
that does.
|
||||||
|
|
||||||
|
When the label fits several products — `"britannia"` names 258 of them — it
|
||||||
|
answers with a short "did you mean?" list instead of picking one, because a
|
||||||
|
confident price on the wrong biscuit is worse than one extra tap.
|
||||||
|
|
||||||
Base path: `/live/api/v1/mob/scan`. Every response uses the usual envelope
|
Base path: `/live/api/v1/mob/scan`. Every response uses the usual envelope
|
||||||
`{ code, status, message, details }`; the shapes below are `details`.
|
`{ code, status, message, details }`; the shapes below are `details`.
|
||||||
|
|
||||||
@@ -17,7 +21,13 @@ Base path: `/live/api/v1/mob/scan`. Every response uses the usual envelope
|
|||||||
photo ──Lens──▶ label
|
photo ──Lens──▶ label
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
POST /lookup ───▶ match + stores[] (recommended first)
|
POST /lookup ───▶ ambiguous:true + candidates[] "did you mean?"
|
||||||
|
│ │
|
||||||
|
│ customer taps one candidate
|
||||||
|
│ │
|
||||||
|
│ POST /lookup { brand, catalogueid }
|
||||||
|
│ │
|
||||||
|
└───▶ match + stores[] (recommended first) ◀──┘
|
||||||
│
|
│
|
||||||
customer taps a store + a size
|
customer taps a store + a size
|
||||||
│
|
│
|
||||||
@@ -26,11 +36,21 @@ photo ──Lens──▶ label
|
|||||||
ok:false + alternative → offer the other store
|
ok:false + alternative → offer the other store
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**`/lookup` has two possible answers and the app must handle both.** A label
|
||||||
|
that names one product comes back with `match` + `stores`. A label that fits
|
||||||
|
several — a bare brand name like `"britannia"`, a generic word like
|
||||||
|
`"biscuits"` — comes back with `ambiguous: true` and `candidates`, and the
|
||||||
|
app asks the customer which one before any price is shown. Lens returns a
|
||||||
|
bare wordmark often, because it is usually the biggest thing printed on a
|
||||||
|
packet, so this is a normal path and not an error case.
|
||||||
|
|
||||||
`GET /stores` is for the "choose another shop" sheet: the customer's
|
`GET /stores` is for the "choose another shop" sheet: the customer's
|
||||||
registered stores, nearest first, independent of any product.
|
registered stores, nearest first, independent of any product.
|
||||||
|
|
||||||
## `POST /lookup`
|
## `POST /lookup`
|
||||||
|
|
||||||
|
Note the `//` notes below are annotations, not JSON — strip them.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"customerid": 5123,
|
"customerid": 5123,
|
||||||
@@ -39,16 +59,25 @@ registered stores, nearest first, independent of any product.
|
|||||||
"longitude": 77.0290,
|
"longitude": 77.0290,
|
||||||
"tenantids": [1135, 1140], // optional: what the app THINKS the customer joined
|
"tenantids": [1135, 1140], // optional: what the app THINKS the customer joined
|
||||||
"limit": 0 // optional: max stores, 0 = all
|
"limit": 0 // optional: max stores, 0 = all
|
||||||
|
|
||||||
|
// Instead of a label: name the product outright. This is how you resolve
|
||||||
|
// a candidate the customer tapped, and how a deep link or a "buy again"
|
||||||
|
// skips recognition. With both set, `label` is ignored.
|
||||||
|
// "brand": "britannia", "catalogueid": 7
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`label` is required **unless** `brand` and `catalogueid` are both given.
|
||||||
|
|
||||||
`tenantids` is verified, never trusted: the server intersects it with the
|
`tenantids` is verified, never trusted: the server intersects it with the
|
||||||
`tenantcustomers` table. Ids the customer is not actually registered with
|
`tenantcustomers` table. Ids the customer is not actually registered with
|
||||||
come back in `unregistered_tenantids` — treat that as "refresh the local
|
come back in `unregistered_tenantids` — treat that as "refresh the local
|
||||||
list". A list that matches nothing at all is treated as stale and all
|
list". A list that matches nothing at all is treated as stale and all
|
||||||
registered stores are used.
|
registered stores are used.
|
||||||
|
|
||||||
Response:
|
### Response A — one product identified
|
||||||
|
|
||||||
|
`ambiguous: false`, `match` set, `candidates` empty.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -59,6 +88,8 @@ Response:
|
|||||||
"image": "https://…", "score": 0.94, "method": "vector+text"
|
"image": "https://…", "score": 0.94, "method": "vector+text"
|
||||||
},
|
},
|
||||||
"catalogue_variants": [ { "…same shape…": "100 g" }, { "…": "200 g" } ],
|
"catalogue_variants": [ { "…same shape…": "100 g" }, { "…": "200 g" } ],
|
||||||
|
"ambiguous": false,
|
||||||
|
"candidates": [],
|
||||||
"confidence": 0.94,
|
"confidence": 0.94,
|
||||||
"available": true,
|
"available": true,
|
||||||
"recommended_locationid": 20,
|
"recommended_locationid": 20,
|
||||||
@@ -83,12 +114,57 @@ Response:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
How to read it:
|
### Response B — several products fit, none clearly
|
||||||
|
|
||||||
- `match == null` → nothing recognised; show `message` and let them retry.
|
`ambiguous: true`, `match: null`, `stores: []`. Show a "did you mean?" list.
|
||||||
`confidence` below ~0.5 → recognised but unsure; confirm the name with the
|
|
||||||
customer before showing prices. `method: "text"` means no embedding model
|
```json
|
||||||
was involved (not configured, or it timed out) — be a little more cautious.
|
{
|
||||||
|
"label": "britannia",
|
||||||
|
"match": null,
|
||||||
|
"ambiguous": true,
|
||||||
|
"candidates": [
|
||||||
|
{ "brand": "britannia", "catalogueid": 23, "product_name": "Britannia Marie Gold",
|
||||||
|
"size": "250 g", "image": "https://…", "score": 0.95, "method": "text", "available": true },
|
||||||
|
{ "brand": "britannia", "catalogueid": 22, "product_name": "Britannia Good Day Butter Cookies",
|
||||||
|
"image": "https://…", "score": 0.95, "method": "text" },
|
||||||
|
{ "brand": "britannia", "catalogueid": 21, "product_name": "Britannia Good Day Cashew Cookies",
|
||||||
|
"image": "https://…", "score": 0.95, "method": "text" }
|
||||||
|
],
|
||||||
|
"confidence": 0.95,
|
||||||
|
"available": false,
|
||||||
|
"stores": [],
|
||||||
|
"catalogue_variants": [],
|
||||||
|
"message": "Which one is it? 1 of these 3 are in stock near you."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`confidence` is not low here, and that is not a bug.** "britannia" really
|
||||||
|
does appear in all three names, so relevance is high — what is missing is
|
||||||
|
*identification*. Gate on `ambiguous`, never on `confidence`: an app that
|
||||||
|
reads 0.95 as "sure enough to show a price" reintroduces the exact bug this
|
||||||
|
path exists to prevent.
|
||||||
|
- **`available` on a candidate** means at least one of the customer's
|
||||||
|
registered stores has it in stock right now. Candidates are ordered
|
||||||
|
available-first, so the list can show what is buyable before what is not
|
||||||
|
— and the field is absent (not `false`) when unavailable, so read it as
|
||||||
|
falsy, not as a required key.
|
||||||
|
- **To resolve a pick**, call `/lookup` again with that candidate's `brand`
|
||||||
|
and `catalogueid` and no label. You get Response A for that exact product,
|
||||||
|
with `method: "direct"` and `confidence: 1`.
|
||||||
|
- At most 10 candidates come back.
|
||||||
|
|
||||||
|
### How to read either response
|
||||||
|
|
||||||
|
- `match == null && !ambiguous` → nothing recognised; show `message` and let
|
||||||
|
them retry with a clearer photo.
|
||||||
|
- `ambiguous: true` → ask, do not guess. Never show a price on this path;
|
||||||
|
`stores` is deliberately empty.
|
||||||
|
- `confidence` below ~0.5 with a `match` → recognised but unsure; worth
|
||||||
|
confirming the name before showing prices. `method: "text"` means no
|
||||||
|
embedding model was involved (not configured, or it timed out) — be a
|
||||||
|
little more cautious. `method: "direct"` means the caller named the
|
||||||
|
product, so nothing was recognised at all.
|
||||||
- `stores` is ordered **in-stock first, then nearest**. Exactly one store has
|
- `stores` is ordered **in-stock first, then nearest**. Exactly one store has
|
||||||
`recommended: true` — the nearest with stock — and only when `available`
|
`recommended: true` — the nearest with stock — and only when `available`
|
||||||
is true. Stores that sell it but have nothing on the shelf are still listed
|
is true. Stores that sell it but have nothing on the shelf are still listed
|
||||||
@@ -100,6 +176,10 @@ How to read it:
|
|||||||
cart/order calls exactly as you would from the catalogue screen.
|
cart/order calls exactly as you would from the catalogue screen.
|
||||||
- `distance_km: -1` means the distance is unknown (no fix from the phone and
|
- `distance_km: -1` means the distance is unknown (no fix from the phone and
|
||||||
no saved address, or the store has no coordinates). Do not render it as 0.
|
no saved address, or the store has no coordinates). Do not render it as 0.
|
||||||
|
Send `latitude`/`longitude` on `/confirm` too if you display distance from
|
||||||
|
its reply: the saved address is only consulted there when the shelf is
|
||||||
|
empty and alternatives have to be ranked, so without a fix the store you
|
||||||
|
tapped comes back `-1`.
|
||||||
|
|
||||||
## `POST /confirm`
|
## `POST /confirm`
|
||||||
|
|
||||||
@@ -115,7 +195,7 @@ nothing is cached on this path.
|
|||||||
{
|
{
|
||||||
"ok": false,
|
"ok": false,
|
||||||
"reason": "out_of_stock", // in_stock | insufficient_stock | out_of_stock | not_sold_here | store_not_registered
|
"reason": "out_of_stock", // in_stock | insufficient_stock | out_of_stock | not_sold_here | store_not_registered
|
||||||
"store": { "…the store they tapped…" },
|
"store": { "…the store they tapped…" }, // distance_km filled from the fix you send
|
||||||
"option": { "productid": 100, "stock": 0, "…": "…" },
|
"option": { "productid": 100, "stock": 0, "…": "…" },
|
||||||
"requested": 2,
|
"requested": 2,
|
||||||
"alternative": { // absent when nobody has enough
|
"alternative": { // absent when nobody has enough
|
||||||
@@ -154,6 +234,18 @@ Same `ScanStore` shape as inside `stores[]` above, without options.
|
|||||||
`EMBEDDING_PROVIDER/MODEL/API_KEY` and **must** be the one that indexed
|
`EMBEDDING_PROVIDER/MODEL/API_KEY` and **must** be the one that indexed
|
||||||
the catalogue — the first search checks the vector width and refuses a
|
the catalogue — the first search checks the vector width and refuses a
|
||||||
mismatch by name.
|
mismatch by name.
|
||||||
|
- **The word match asks for most of the label, not all of it**
|
||||||
|
(`minTokenHits`: two thirds, rounded up, and both of a two-word label).
|
||||||
|
Requiring every word meant one word the catalogue does not use took the
|
||||||
|
right product out of the running entirely — "Dettol bottle pack" retrieved
|
||||||
|
no Dettol, "Parle G biscuit pack" retrieved no Parle-G — and the vector
|
||||||
|
search then answered alone, confidently and wrongly, at a score the floor
|
||||||
|
could not catch. Each brand's rows are ordered by how much of the label
|
||||||
|
they carry (the whole label as a substring outranks any number of loose
|
||||||
|
words) so that the per-brand `LIMIT` keeps the best rows and not merely the
|
||||||
|
first ones the planner reached. Packaging words — "pack", "bottle", "jar",
|
||||||
|
"sachet" and friends, see `utils.isPackaging` — are dropped before any of
|
||||||
|
this, like pack sizes, unless the label is nothing else.
|
||||||
- **The catalogue's model** (verified 2026-09-15 by cosine against a stored
|
- **The catalogue's model** (verified 2026-09-15 by cosine against a stored
|
||||||
row: 1.0000): `all-MiniLM-L6-v2`, 384-d, unit-normalised, embedding the
|
row: 1.0000): `all-MiniLM-L6-v2`, 384-d, unit-normalised, embedding the
|
||||||
`search_query` column (brand + name + category + blurb + price range).
|
`search_query` column (brand + name + category + blurb + price range).
|
||||||
@@ -167,10 +259,12 @@ Same `ScanStore` shape as inside `stores[]` above, without options.
|
|||||||
EMBEDDING_DIMENSIONS=384
|
EMBEDDING_DIMENSIONS=384
|
||||||
```
|
```
|
||||||
A bare label ("Milk Bikis") scores ~0.92 against its product's stored
|
A bare label ("Milk Bikis") scores ~0.92 against its product's stored
|
||||||
vector and ~0.23 against an unrelated one, which is what the 0.30 floor in
|
vector and ~0.23 against an unrelated one, which is what the 0.50 floor in
|
||||||
`scanService.go` is set against. If the catalogue team ever re-embeds
|
`scanService.go` is set against — the middle of that split, not the edge of
|
||||||
with another model, change `EMBEDDING_MODEL`/`DIMENSIONS` here and
|
the noise. It was 0.30 until a near-miss got through in production
|
||||||
nothing else.
|
("Paracetamol" → "Paneer Makhni 500ml", 0.304). If the catalogue team ever
|
||||||
|
re-embeds with another model, change `EMBEDDING_MODEL`/`DIMENSIONS` here
|
||||||
|
and nothing else.
|
||||||
- **Speed**: the label's vector (7 days) and the ranked catalogue hits
|
- **Speed**: the label's vector (7 days) and the ranked catalogue hits
|
||||||
(30 min) are cached in Redis and in-process, so a popular product costs
|
(30 min) are cached in Redis and in-process, so a popular product costs
|
||||||
one model call platform-wide. Customer, stores and catalogue are read
|
one model call platform-wide. Customer, stores and catalogue are read
|
||||||
@@ -187,6 +281,59 @@ Same `ScanStore` shape as inside `stores[]` above, without options.
|
|||||||
- **Identity** is the `customerid` in the body, like every other mobile
|
- **Identity** is the `customerid` in the body, like every other mobile
|
||||||
endpoint here — there is no auth layer yet (see `SECURITY_HANDOFF.md`).
|
endpoint here — there is no auth layer yet (see `SECURITY_HANDOFF.md`).
|
||||||
|
|
||||||
|
## Two decisions, and why
|
||||||
|
|
||||||
|
Both come from a proposal (2026-09-23) to have the app send vectors it
|
||||||
|
computed on the phone. Recorded here because the next person will ask.
|
||||||
|
|
||||||
|
### The app does not send `textvector`
|
||||||
|
|
||||||
|
An on-device MiniLM vector is only comparable to the catalogue's if the app
|
||||||
|
ships the identical model *and* tokenizer *and* pooling *and* normalisation;
|
||||||
|
a quantised tflite build usually drifts, and the failure is silent — the
|
||||||
|
ranking just gets worse. There is also nothing to gain: the server-side
|
||||||
|
embed is ~30 ms warm and the result is cached in Redis by label, so one
|
||||||
|
model call serves every customer who scans that product. A client-supplied
|
||||||
|
vector *defeats* that cache (the key would have to be the vector, not the
|
||||||
|
label), and 384 floats is ~5 KB of upload against ~12 bytes for
|
||||||
|
`"Milk Bikis"`. If the field ever arrives it can be accepted and validated,
|
||||||
|
but the app should not be asked to compute it.
|
||||||
|
|
||||||
|
**Send the full OCR text instead** if you want to give the server more to
|
||||||
|
work with — ~100 bytes, no model coupling, strictly more information than a
|
||||||
|
single label.
|
||||||
|
|
||||||
|
### The app does not send `imagevector` — yet
|
||||||
|
|
||||||
|
The catalogue *does* carry image vectors: every `brand_*` table has
|
||||||
|
`img_vector vector(1024)`, filled on 1885 of 2124 rows (empty in
|
||||||
|
`brand_haldirams`, `brand_kaleesuwari`, `brand_mdh`, `brand_zzsmoketest`).
|
||||||
|
That matches the proposed MobileNetV3-Small embedder, so the idea is
|
||||||
|
coherent and half-built — this flow simply does not read that column.
|
||||||
|
|
||||||
|
It stays unread for now because **Google Lens is already the image
|
||||||
|
recogniser, and a far better one**: photo → Lens → label is Google's product
|
||||||
|
recognition, trained on billions of images. Putting a 137M-parameter
|
||||||
|
ImageNet backbone searching 1885 vectors *behind* that adds little where
|
||||||
|
Lens succeeds, and MobileNetV3-Small — which struggles to tell one blue
|
||||||
|
biscuit wrapper from another — is unlikely to rescue the cases where Lens
|
||||||
|
fails. There is also an unverified dependency: the preprocessing the app
|
||||||
|
would use (BGR → centre crop → 224×224 INTER_AREA → RGB → `/255.0`) has to
|
||||||
|
match whatever the catalogue pipeline actually ran, or the search returns
|
||||||
|
confidently-ranked noise.
|
||||||
|
|
||||||
|
**What would change this:** the field data. Once live, count how often
|
||||||
|
`/lookup` returns `ambiguous: true` or nothing recognised. If Lens labels are
|
||||||
|
reliable, image search is polish; if that number is high, it becomes the
|
||||||
|
priority — and the first task is the cosine check (embed a known catalogue
|
||||||
|
product's image through the app's exact pipeline, compare with its stored
|
||||||
|
`img_vector`; ≈0.99 means the contract holds), not writing the query.
|
||||||
|
|
||||||
|
There is one non-recognition argument for it worth remembering: on-device
|
||||||
|
inference is free and needs no Google dependency, which matters if Cloud
|
||||||
|
Vision costs start to bite at volume. That is a business reason, not a
|
||||||
|
quality one.
|
||||||
|
|
||||||
## For backend developers
|
## For backend developers
|
||||||
|
|
||||||
### Where the code is
|
### Where the code is
|
||||||
@@ -201,7 +348,7 @@ Same `ScanStore` shape as inside `stores[]` above, without options.
|
|||||||
| `utils/embedding.go` | `Embedder` interface, OpenAI-compatible and Gemini clients |
|
| `utils/embedding.go` | `Embedder` interface, OpenAI-compatible and Gemini clients |
|
||||||
| `utils/geo.go` | coordinate parsing, haversine, opening hours, label tokenising |
|
| `utils/geo.go` | coordinate parsing, haversine, opening hours, label tokenising |
|
||||||
| `config/config.go` | `EmbeddingConfig` and its validation |
|
| `config/config.go` | `EmbeddingConfig` and its validation |
|
||||||
| `scratch/cataloguedims` | read-only check of the catalogue's embedding width / fill |
|
| `scratch/cataloguedims` | read-only check of every catalogue vector column's width and fill |
|
||||||
|
|
||||||
### Try it locally
|
### Try it locally
|
||||||
|
|
||||||
@@ -220,11 +367,13 @@ anything; a schema-only dump does not.
|
|||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
`go test ./services -run 'Lookup|Confirm|Stores|CatalogueFamily'` drives
|
`go test ./services -run 'Lookup|Confirm|Stores|Brand|Ambiguous|Specific|TextScore|Distinct|Naming'`
|
||||||
the whole pipeline through a fake repository (`services/scan_test.go`); no
|
drives the whole pipeline through a fake repository
|
||||||
database. `go test ./utils` covers both HTTP clients against `httptest`
|
(`services/scan_test.go`); no database. `go test ./utils` covers both HTTP
|
||||||
servers, and the geo helpers. Add a case to `scan_test.go`'s fixture when
|
clients against `httptest` servers, and the geo helpers. Add a case to
|
||||||
you change ranking — it is the spec.
|
`scan_test.go`'s fixture when you change ranking — it is the spec, and
|
||||||
|
`newBrandLabelFixture` in particular is the regression guard for the
|
||||||
|
brand-name bug described under Scoring.
|
||||||
|
|
||||||
### Knobs (constants in `scanService.go`)
|
### Knobs (constants in `scanService.go`)
|
||||||
|
|
||||||
@@ -232,13 +381,44 @@ you change ranking — it is the spec.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `scanLookupTimeout` | 5 s | whole lookup, including the model call |
|
| `scanLookupTimeout` | 5 s | whole lookup, including the model call |
|
||||||
| `scanCatalogueTopK` | 15 | rows taken from each brand table and from the merge |
|
| `scanCatalogueTopK` | 15 | rows taken from each brand table and from the merge |
|
||||||
| `scanMinScore` | 0.30 | below this the best hit is not shown as a match |
|
| `scanMinScore` | 0.50 | below this the best hit is not shown as a match |
|
||||||
|
| `scanAmbiguityMargin` | 0.06 | how close the runner-up may be before the answer becomes a question |
|
||||||
|
| `scanMaxCandidates` | 10 | longest "did you mean?" list |
|
||||||
| `embedTimeout` (`utils/embedding.go`) | 4 s | one model call |
|
| `embedTimeout` (`utils/embedding.go`) | 4 s | one model call |
|
||||||
| `scanVectorTTL` / `scanHitsTTL` (`scanRepository.go`) | 7 d / 30 min | cache lifetimes |
|
| `scanVectorTTL` / `scanHitsTTL` (`scanRepository.go`) | 7 d / 30 min | cache lifetimes |
|
||||||
|
|
||||||
Scores: vector = `1 − cosine distance`; text = 0.95 for the whole label
|
Scores: vector = `1 − cosine distance`; text = 0.95 for the whole label
|
||||||
inside the name, else `0.8 × (label words found / label words)`; combined =
|
inside the name, else `0.8 × (label words found / label words)`; combined =
|
||||||
`max(vector, text) + 0.10` when both hit, capped at 1.
|
`max(vector, text) + 0.10` when both hit, capped at 1. Ties are broken by
|
||||||
|
cosine distance — nearest first, a text-only row last — and only then by
|
||||||
|
name.
|
||||||
|
|
||||||
|
The label and the product name are both separator-folded before that
|
||||||
|
substring test (`utils.FoldSeparators`), and compared again with separators
|
||||||
|
removed (`utils.TightenLabel`, labels of 4+ characters), so the brand's own
|
||||||
|
punctuation does not decide the match: "Parle G", "Parle-G" and "ParleG" all
|
||||||
|
reach *Parle-G Original Glucose Biscuits*. A single-character token survives
|
||||||
|
tokenising when it follows a word, because it is often the whole name — the
|
||||||
|
"G" of Parle-G, the "K" of Special K. It is still dropped when it stands
|
||||||
|
alone or is a pack multiplier.
|
||||||
|
|
||||||
|
All three mattered at once: before this, "Parle G" tied with *Parle Monaco
|
||||||
|
Classic* at 0.9 (the "G" was dropped, so only "parle" matched either row),
|
||||||
|
and the name tie-break handed it to Monaco because a space precedes a hyphen
|
||||||
|
in ASCII. A confident, wrong answer — the kind no score floor can catch.
|
||||||
|
|
||||||
|
**When the substring rule ties, that tie is the answer.** A bare brand name
|
||||||
|
is a substring of every one of that brand's names, so all of them score 0.95
|
||||||
|
— identically, at a high score no floor would ever catch. Rather than
|
||||||
|
scoring around it, `isAmbiguous` reads it: if the runner-up is within
|
||||||
|
`scanAmbiguityMargin` of the leader, the reply becomes `ambiguous: true`
|
||||||
|
with `candidates` instead of a match (see Response B). Erring towards asking
|
||||||
|
is deliberate — one tap on a picture against the wrong biscuit. A label that
|
||||||
|
names one product leaves the runner-up far behind, so the common case is
|
||||||
|
untouched, and `services/scan_test.go`'s
|
||||||
|
`TestABrandNameScoresItsProductsIdentically` guards the tie itself: a
|
||||||
|
formula that broke it on name length or word count would bring the bug
|
||||||
|
back.
|
||||||
|
|
||||||
### Changing the embedding model
|
### Changing the embedding model
|
||||||
|
|
||||||
|
|||||||
@@ -39,11 +39,41 @@ inside a git repository. `.gitignore` excludes `*.sql` here for that reason.
|
|||||||
|
|
||||||
## Getting something to test against
|
## Getting something to test against
|
||||||
|
|
||||||
An empty schema boots but has no tenants, so there is nothing to sign in as.
|
`nearledb/02-seed.sql` is committed and applied automatically, so a fresh
|
||||||
Two options:
|
volume already has a merchant to sign into. It invents one rather than copying
|
||||||
|
one, which is why it can live here at all.
|
||||||
|
|
||||||
- **Onboard a tenant through the console** once it is pointed at localhost.
|
| Account | Password | Opens |
|
||||||
That exercises the real path and is usually what you want.
|
|---|---|---|
|
||||||
- **Copy a few rows** you actually need — a tenant, its locations, its
|
| `super@nearle.invalid` | `localdev` | Nearle Admin — the platform workspace |
|
||||||
app_users — with `pg_dump --data-only --table=...`. Check what you are
|
| `admin@testmart.invalid` | `localdev` | Store Admin — all of Testmart's branches |
|
||||||
copying: `app_users.password` is stored in clear.
|
| `main@testmart.invalid` | `localdev` | Store user — Testmart Main only |
|
||||||
|
|
||||||
|
It also seeds the role ladder, three aisles under category 2, and a second
|
||||||
|
merchant (`Halfmart`) deliberately left in the broken `categoryid = 0` shape as
|
||||||
|
a permanent regression fixture. The sequences are moved past the seeded ids at
|
||||||
|
the end, so the first row you create locally does not come back as id 1.
|
||||||
|
|
||||||
|
If you need something it does not cover:
|
||||||
|
|
||||||
|
- **Onboard a tenant through the console.** That exercises the real path and is
|
||||||
|
usually what you want.
|
||||||
|
- **Copy a few rows** you actually need with `pg_dump --data-only --table=...`.
|
||||||
|
Check what you are copying: `app_users.password` is stored in clear.
|
||||||
|
|
||||||
|
## The catalogue database
|
||||||
|
|
||||||
|
`cataloguedb/02-seed.sql` is committed too, and also entirely invented. The
|
||||||
|
real catalogue is another team's scrape of real retailers and a dump of it does
|
||||||
|
not belong on a laptop.
|
||||||
|
|
||||||
|
Without it the catalogue database exists but holds no catalogue: every
|
||||||
|
`brand_*` table is missing, `getbrands` answers 500, and the global catalogue
|
||||||
|
screen, the import flow and `importcatalogueproduct` cannot be exercised at
|
||||||
|
all. The seed gives you two brands:
|
||||||
|
|
||||||
|
- `brand_testbrand` — every column the reader knows about, four products, one
|
||||||
|
of them deliberately with no images.
|
||||||
|
- `brand_sparsebrand` — only `id`, `product_name` and a price, to keep the
|
||||||
|
degraded-but-still-listed path covered. Brands are discovered by table name,
|
||||||
|
so adding another is just another `brand_*` table.
|
||||||
|
|||||||
109
init/cataloguedb/02-seed.sql
Normal file
109
init/cataloguedb/02-seed.sql
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
-- A synthetic global catalogue to develop against.
|
||||||
|
--
|
||||||
|
-- INVENTED DATA, exactly like `nearledb/02-seed.sql` and for the same reason:
|
||||||
|
-- the real catalogue is somebody else's scrape of real retailers, and a dump of
|
||||||
|
-- it does not belong on a laptop inside a git repository.
|
||||||
|
--
|
||||||
|
-- ── Why this file has to exist ──────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- `init/cataloguedb/` was empty, so a local stack had a catalogue DATABASE with
|
||||||
|
-- no catalogue in it. Every `brand_*` table was missing, `getbrands` answered
|
||||||
|
-- 500, and the whole catalogue-import path — the global catalogue screen, the
|
||||||
|
-- import flow, `importcatalogueproduct` — could not be exercised locally at
|
||||||
|
-- all. It is a documented feature with its own integration doc and it had no
|
||||||
|
-- local coverage whatsoever.
|
||||||
|
--
|
||||||
|
-- ── The shape ───────────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Brands are discovered from `information_schema` by table name, so a table
|
||||||
|
-- called `brand_<something>` IS a brand; there is no registry to add it to.
|
||||||
|
-- `catalogueCoreColumns` requires only `id` and `product_name` — everything
|
||||||
|
-- else is selected when present and replaced with NULL when absent, so a
|
||||||
|
-- partial table degrades rather than disappearing. These two are written full
|
||||||
|
-- so that the degraded path is a deliberate test, not the only thing available:
|
||||||
|
-- `brand_testbrand` has every column, and `brand_sparsebrand` deliberately has
|
||||||
|
-- only the core two plus a price, to exercise `columnsFor`.
|
||||||
|
--
|
||||||
|
-- `image_id` is the durable key across re-scrapes — catalogue ids are not
|
||||||
|
-- stable and `models.Products.Imageid` is what the import stores — so every
|
||||||
|
-- product here has one and they are distinct.
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
|
||||||
|
-- ── A brand with the full column set ────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS brand_testbrand (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
product_name TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
description TEXT,
|
||||||
|
category TEXT,
|
||||||
|
image_id TEXT,
|
||||||
|
size TEXT,
|
||||||
|
variant_key TEXT,
|
||||||
|
product_sku TEXT,
|
||||||
|
sku_source TEXT,
|
||||||
|
-- A RANGE, not a price. The global catalogue carries what retailers were
|
||||||
|
-- seen charging; the shop sets its own price at import time, which is why
|
||||||
|
-- the console collects one before an import can be enabled.
|
||||||
|
price_range TEXT,
|
||||||
|
providers TEXT[],
|
||||||
|
fssai_license TEXT,
|
||||||
|
highlights TEXT[],
|
||||||
|
nutrients TEXT[],
|
||||||
|
search_query TEXT,
|
||||||
|
image_url TEXT,
|
||||||
|
image_urls TEXT[],
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO brand_testbrand
|
||||||
|
(product_name, title, description, category, image_id, size, variant_key,
|
||||||
|
product_sku, sku_source, price_range, providers, fssai_license,
|
||||||
|
highlights, nutrients, search_query, image_url, image_urls)
|
||||||
|
VALUES
|
||||||
|
('Testbrand Basmati Rice 5kg', 'Testbrand Basmati Rice', 'Long grain basmati, aged twelve months.',
|
||||||
|
'Rice & Grains', 'IMG-TB-RICE-5K', '5 kg', 'rice-5kg', 'TB-RICE-5K', 'scrape',
|
||||||
|
'380-420', ARRAY['bigbasket','amazon'], '12345678901234',
|
||||||
|
ARRAY['Aged 12 months','Extra long grain'], ARRAY['Energy 350kcal','Protein 7g'],
|
||||||
|
'basmati rice 5kg', 'https://placehold.co/300x300?text=Rice5kg',
|
||||||
|
ARRAY['https://placehold.co/300x300?text=Rice5kg','https://placehold.co/300x300?text=Rice5kg-back']),
|
||||||
|
|
||||||
|
('Testbrand Basmati Rice 1kg', 'Testbrand Basmati Rice', 'Long grain basmati, aged twelve months.',
|
||||||
|
'Rice & Grains', 'IMG-TB-RICE-1K', '1 kg', 'rice-1kg', 'TB-RICE-1K', 'scrape',
|
||||||
|
'85-99', ARRAY['bigbasket'], '12345678901234',
|
||||||
|
ARRAY['Aged 12 months'], ARRAY['Energy 350kcal','Protein 7g'],
|
||||||
|
'basmati rice 1kg', 'https://placehold.co/300x300?text=Rice1kg',
|
||||||
|
ARRAY['https://placehold.co/300x300?text=Rice1kg']),
|
||||||
|
|
||||||
|
('Testbrand Sunflower Oil 1L', 'Testbrand Sunflower Oil', 'Refined sunflower oil, light and neutral.',
|
||||||
|
'Oils & Ghee', 'IMG-TB-OIL-1L', '1 L', 'oil-1l', 'TB-OIL-1L', 'scrape',
|
||||||
|
'150-185', ARRAY['bigbasket','jiomart'], '99999999999999',
|
||||||
|
ARRAY['Vitamin E','Light frying'], ARRAY['Energy 900kcal','Fat 100g'],
|
||||||
|
'sunflower oil 1 litre', 'https://placehold.co/300x300?text=Oil1L',
|
||||||
|
ARRAY['https://placehold.co/300x300?text=Oil1L']),
|
||||||
|
|
||||||
|
-- No images at all. `ImportCatalogueProduct` only sets `productimages` when
|
||||||
|
-- the product has photos, so this row is the one that proves an import still
|
||||||
|
-- works when it does not — the case that used to hit the jsonb empty-string
|
||||||
|
-- failure in `products`.
|
||||||
|
('Testbrand Salt 1kg', 'Testbrand Iodised Salt', 'Free-flowing iodised salt.',
|
||||||
|
'Everyday', 'IMG-TB-SALT-1K', '1 kg', 'salt-1kg', 'TB-SALT-1K', 'scrape',
|
||||||
|
'20-28', ARRAY['jiomart'], NULL,
|
||||||
|
NULL, NULL, 'iodised salt 1kg', NULL, NULL);
|
||||||
|
|
||||||
|
-- ── A brand with only the core columns ──────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Discovery used to demand all eighteen columns, which made a table like this
|
||||||
|
-- INVISIBLE rather than merely thin — 16 of 35 live brands were unreachable
|
||||||
|
-- from this side for exactly that reason. Keeping one here means the
|
||||||
|
-- degraded-but-listed path is covered by the seed and stays covered.
|
||||||
|
CREATE TABLE IF NOT EXISTS brand_sparsebrand (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
product_name TEXT NOT NULL,
|
||||||
|
price_range TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO brand_sparsebrand (product_name, price_range) VALUES
|
||||||
|
('Sparsebrand Biscuits 100g', '20-30'),
|
||||||
|
('Sparsebrand Tea 250g', '110-140');
|
||||||
@@ -161,4 +161,72 @@ INSERT INTO productstocks (
|
|||||||
(9504, 9001, 9102, 9301, NOW(), 'in', 12, 'Active')
|
(9504, 9001, 9102, 9301, NOW(), 'in', 12, 'Active')
|
||||||
ON CONFLICT (productstockid) DO NOTHING;
|
ON CONFLICT (productstockid) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── The platform operator ───────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Without this there is nobody who can open the Nearle Admin workspace, which
|
||||||
|
-- is the one this console was built for first. `resolveRole` checks
|
||||||
|
-- `issuperadmin` BEFORE roleid — deliberately, because the flag is derived by
|
||||||
|
-- the server and a roleid is just a number in a row — so no amount of role 1
|
||||||
|
-- gets you in without it, and every local session landed in Store Admin
|
||||||
|
-- instead. The accounts above are one per role and this was the role they were
|
||||||
|
-- missing.
|
||||||
|
--
|
||||||
|
-- Not attached to either merchant in spirit, only in columns: a platform
|
||||||
|
-- operator has to carry a tenantid because the column is not nullable, and
|
||||||
|
-- nothing in the admin workspace reads it.
|
||||||
|
INSERT INTO app_users (
|
||||||
|
userid, authname, firstname, lastname, email, dialcode, contactno,
|
||||||
|
configid, roleid, password, tenantid, locationid, applocationid,
|
||||||
|
status, issuperadmin
|
||||||
|
) VALUES
|
||||||
|
(9299, 'super@nearle.invalid', 'Nearle', 'Operator', 'super@nearle.invalid',
|
||||||
|
'+91', '9000009999', 1, 1, 'localdev', 9001, 9101, 9001, 'Active', true)
|
||||||
|
ON CONFLICT (userid) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── The role ladder ─────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- `getstaffs` LEFT JOINs app_roles for `rolename`, so an empty table is not an
|
||||||
|
-- error — every person on Users & access simply reads "—" where their role
|
||||||
|
-- should be. The ids are the ones the rest of the system already assumes:
|
||||||
|
-- 1 and 3 reach Store Admin, 4 is a branch manager, 7 and 8 are till accounts
|
||||||
|
-- and are excluded from every back-office query by the backend itself.
|
||||||
|
INSERT INTO app_roles (roleid, rolename, configid) VALUES
|
||||||
|
(1, 'Super admin', 1),
|
||||||
|
(3, 'Admin', 1),
|
||||||
|
(4, 'Manager', 1),
|
||||||
|
(7, 'Supervisor', 1),
|
||||||
|
(8, 'Cashier', 1)
|
||||||
|
ON CONFLICT (roleid) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── Aisles under the category the customer app browses ──────────────────────
|
||||||
|
--
|
||||||
|
-- categoryid 2 is the only category the app lists, and the aisle a shopper
|
||||||
|
-- reads is the SUBCATEGORY. With none of these the sheet importer has nothing
|
||||||
|
-- to resolve a row's category against, so every imported product falls back to
|
||||||
|
-- subcategoryid 0 and lands under "Uncategorized".
|
||||||
|
INSERT INTO productsubcategories (subcatid, categoryid, tenantid, subcatname, status, sortorder)
|
||||||
|
VALUES
|
||||||
|
(9601, 2, 9001, 'Rice & Grains', 'Active', 1),
|
||||||
|
(9602, 2, 9001, 'Oils & Ghee', 'Active', 2),
|
||||||
|
(9603, 2, 9001, 'Snacks', 'Active', 3)
|
||||||
|
ON CONFLICT (subcatid) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── Move the sequences past the seeded ids ──────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Everything above inserts an explicit id, which does NOT advance the sequence
|
||||||
|
-- behind that column. So the first tenant, outlet or product created against a
|
||||||
|
-- fresh local database came back as id 1 — harmless here, but it means local
|
||||||
|
-- ids look nothing like the ones the same code produces in production, and a
|
||||||
|
-- seed that ever collides with a sequence value fails on a duplicate key.
|
||||||
|
--
|
||||||
|
-- `GREATEST(..., 1)` because setval refuses a value below the sequence minimum,
|
||||||
|
-- and a table the seed does not touch is legitimately empty.
|
||||||
|
SELECT setval('tenants_tenantid_seq', GREATEST((SELECT COALESCE(MAX(tenantid),0) FROM tenants), 1));
|
||||||
|
SELECT setval('tenantlocations_locationid_seq', GREATEST((SELECT COALESCE(MAX(locationid),0) FROM tenantlocations), 1));
|
||||||
|
SELECT setval('app_users_userid_seq', GREATEST((SELECT COALESCE(MAX(userid),0) FROM app_users), 1));
|
||||||
|
SELECT setval('products_productid_seq', GREATEST((SELECT COALESCE(MAX(productid),0) FROM products), 1));
|
||||||
|
SELECT setval('productlocations_productlocationid_seq', GREATEST((SELECT COALESCE(MAX(productlocationid),0) FROM productlocations), 1));
|
||||||
|
SELECT setval('productstocks_productstockid_seq', GREATEST((SELECT COALESCE(MAX(productstockid),0) FROM productstocks), 1));
|
||||||
|
SELECT setval('customers_customerid_seq', GREATEST((SELECT COALESCE(MAX(customerid),0) FROM customers), 1));
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
86
main.go
86
main.go
@@ -79,6 +79,38 @@ func main() {
|
|||||||
log.Println("⚠️ could not add products.productimages, extra photos will not be stored:", err)
|
log.Println("⚠️ could not add products.productimages, extra photos will not be stored:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What the global catalogue knew about this product, kept.
|
||||||
|
//
|
||||||
|
// The import copies eight of the catalogue's eighteen fields onto the
|
||||||
|
// tenant's product and left the other ten behind — among them the FSSAI
|
||||||
|
// licence, the nutrition lines, the highlights, the provider list, the
|
||||||
|
// price range and the variant key. The console needs exactly those to
|
||||||
|
// decide what to charge, so `ProductDrawer` went back to the catalogue for
|
||||||
|
// them on every open.
|
||||||
|
//
|
||||||
|
// That lookup is not a substitute for storing them. A tenant's product is a
|
||||||
|
// SNAPSHOT and outlives its source row: the catalogue is re-scraped, a
|
||||||
|
// variant is retired, and the licence number and the nutrition panel for a
|
||||||
|
// product the shop is still selling are gone with no way back. Measured
|
||||||
|
// locally by retiring one row — the product survived, everything the drawer
|
||||||
|
// shows about it did not.
|
||||||
|
//
|
||||||
|
// One jsonb column rather than six typed ones, and rather than the
|
||||||
|
// `productspecs` table that has sat unused since the schema was written.
|
||||||
|
// The value is a snapshot of somebody else's record, read as a whole and
|
||||||
|
// displayed as a whole — it is never joined, aggregated or filtered — and
|
||||||
|
// the catalogue grows fields faster than this side can add migrations.
|
||||||
|
// Postgres can still reach inside it (`cataloguefacts->>'fssai_license'`)
|
||||||
|
// on the day somebody needs to. `productimages` beside it made the same
|
||||||
|
// call for the same reason.
|
||||||
|
//
|
||||||
|
// Not fatal on failure, exactly like the column above: a product without
|
||||||
|
// its catalogue facts is the product we have today.
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`ALTER TABLE products ADD COLUMN IF NOT EXISTS cataloguefacts jsonb`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not add products.cataloguefacts, catalogue detail will not survive a re-scrape:", err)
|
||||||
|
}
|
||||||
|
|
||||||
// When a product became visible to a store, and the only thing that decides
|
// When a product became visible to a store, and the only thing that decides
|
||||||
// whether it is.
|
// whether it is.
|
||||||
//
|
//
|
||||||
@@ -173,6 +205,60 @@ func main() {
|
|||||||
log.Println("productvariants.variantid given a key generator (one time)")
|
log.Println("productvariants.variantid given a key generator (one time)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Key generators for the two partner tables, for exactly the reason above.
|
||||||
|
//
|
||||||
|
// `partnerinfo.partnerid` and `partnerlocations.partnerlocationid` are both
|
||||||
|
// NOT NULL with no default and no identity, so GORM — which sends nothing
|
||||||
|
// for a key it expects the database to mint — had every insert refused with
|
||||||
|
// a not-null violation. `createpartner` therefore could not write a partner
|
||||||
|
// OR its regions: the endpoint exists, the form exists, and the row could
|
||||||
|
// never land. The five partners on the platform were all inserted by hand,
|
||||||
|
// which is the symptom rather than a choice.
|
||||||
|
//
|
||||||
|
// This matters more than one broken button. `GetPartners` now separates the
|
||||||
|
// partners registered through this console from the ones another product
|
||||||
|
// left in the shared `partnerinfo` by joining `partnerlocations` — and only
|
||||||
|
// a successful create writes that table. Without a key generator no partner
|
||||||
|
// can ever be registered, so nothing would ever have a link row and the
|
||||||
|
// Rider partners page would be empty forever.
|
||||||
|
//
|
||||||
|
// Both sequences start above the ids already there, so the hand-inserted
|
||||||
|
// rows keep theirs.
|
||||||
|
for _, key := range []struct{ table, column string }{
|
||||||
|
{"partnerinfo", "partnerid"},
|
||||||
|
{"partnerlocations", "partnerlocationid"},
|
||||||
|
} {
|
||||||
|
var keyed int64
|
||||||
|
if err := db.DB.Raw(`
|
||||||
|
SELECT COUNT(1) FROM information_schema.columns
|
||||||
|
WHERE table_name = ? AND column_name = ?
|
||||||
|
AND (column_default IS NOT NULL OR is_identity = 'YES')`,
|
||||||
|
key.table, key.column).Scan(&keyed).Error; err != nil {
|
||||||
|
log.Fatalf("could not check %s.%s: %v", key.table, key.column, err)
|
||||||
|
}
|
||||||
|
if keyed > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seq := key.table + "_" + key.column + "_seq"
|
||||||
|
if err := db.DB.Exec(fmt.Sprintf(
|
||||||
|
`CREATE SEQUENCE IF NOT EXISTS %s START WITH 1 OWNED BY %s.%s`,
|
||||||
|
seq, key.table, key.column)).Error; err != nil {
|
||||||
|
log.Fatalf("could not create %s: %v", seq, err)
|
||||||
|
}
|
||||||
|
if err := db.DB.Exec(fmt.Sprintf(
|
||||||
|
`SELECT setval('%s', COALESCE((SELECT MAX(%s) FROM %s), 0) + 1, false)`,
|
||||||
|
seq, key.column, key.table)).Error; err != nil {
|
||||||
|
log.Fatalf("could not position %s: %v", seq, err)
|
||||||
|
}
|
||||||
|
if err := db.DB.Exec(fmt.Sprintf(
|
||||||
|
`ALTER TABLE %s ALTER COLUMN %s SET DEFAULT nextval('%s')`,
|
||||||
|
key.table, key.column, seq)).Error; err != nil {
|
||||||
|
log.Fatalf("could not default %s.%s: %v", key.table, key.column, err)
|
||||||
|
}
|
||||||
|
log.Printf("%s.%s given a key generator (one time)", key.table, key.column)
|
||||||
|
}
|
||||||
|
|
||||||
// The catalogue's own stable key for an imported product.
|
// The catalogue's own stable key for an imported product.
|
||||||
//
|
//
|
||||||
// `catalogueid` was never able to be this. The catalogue is rebuilt by
|
// `catalogueid` was never able to be this. The catalogue is rebuilt by
|
||||||
|
|||||||
@@ -296,10 +296,20 @@ type NewPartner struct {
|
|||||||
Where they work — ONE district, not a set.
|
Where they work — ONE district, not a set.
|
||||||
|
|
||||||
`Applocationid` is the home region and goes on the partner row itself,
|
`Applocationid` is the home region and goes on the partner row itself,
|
||||||
because `GetPartners` filters on it and the rider app reads it.
|
because the rider app reads it. The same region is also written to
|
||||||
`Applocationids` is every region they cover and goes to
|
`partnerlocations`, which is the table that may hold SEVERAL — a partner
|
||||||
`partnerlocations` — one partner routinely serves several cities, and
|
routinely serves more than one city, and that is why the link table
|
||||||
that is the whole reason the link table exists.
|
exists, and partners with two are live — partner 44 covers regions 1 and
|
||||||
|
2. Nothing on THIS path creates one: `regionsOf` returns this single
|
||||||
|
field and the console's form offers one district, never a set. So a
|
||||||
|
multi-region partner can be read and must be handled, but cannot yet be
|
||||||
|
made here.
|
||||||
|
|
||||||
|
`GetPartners` reads the link table rather than this field, for two
|
||||||
|
reasons. It is the column allowed to grow, so a partner who covers a
|
||||||
|
second city will be found there without another change. And
|
||||||
|
`partnerinfo` is shared with another product that writes no link rows,
|
||||||
|
so having one is what marks a partner as ours.
|
||||||
*/
|
*/
|
||||||
Applocationid int `json:"applocationid"`
|
Applocationid int `json:"applocationid"`
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -155,6 +155,23 @@ type Products struct {
|
|||||||
// `catalogueProductColumns` casts its text[] columns to text.
|
// `catalogueProductColumns` casts its text[] columns to text.
|
||||||
Productimages string `json:"productimages,omitempty" gorm:"column:productimages;type:jsonb"`
|
Productimages string `json:"productimages,omitempty" gorm:"column:productimages;type:jsonb"`
|
||||||
|
|
||||||
|
// The catalogue's own record of this product, as it stood at import.
|
||||||
|
//
|
||||||
|
// Holds the fields the snapshot does not have columns for — fssai_license,
|
||||||
|
// highlights, nutrients, providers, price_range, variant_key, title,
|
||||||
|
// sku_source, search_query — so the console can show them without asking
|
||||||
|
// the catalogue again. It asked on every drawer open, and got nothing back
|
||||||
|
// the moment a re-scrape retired the source row, taking a licence number
|
||||||
|
// and a nutrition panel off a product the shop was still selling.
|
||||||
|
//
|
||||||
|
// Empty for anything that did not come from the catalogue: a sheet-imported
|
||||||
|
// product has no such record, and the drawer falls back to the live lookup
|
||||||
|
// for those exactly as before.
|
||||||
|
//
|
||||||
|
// A string for the same reason `Productimages` is one — GORM's raw
|
||||||
|
// scan-into-struct silently drops slice- and map-kind destination fields.
|
||||||
|
Cataloguefacts string `json:"cataloguefacts,omitempty" gorm:"column:cataloguefacts;type:jsonb"`
|
||||||
|
|
||||||
Productdesc string `json:"productdesc,omitempty"`
|
Productdesc string `json:"productdesc,omitempty"`
|
||||||
Productsku string `json:"productsku,omitempty"`
|
Productsku string `json:"productsku,omitempty"`
|
||||||
Brandid int `json:"brandid,omitempty"`
|
Brandid int `json:"brandid,omitempty"`
|
||||||
@@ -225,6 +242,28 @@ type Locationproducts struct {
|
|||||||
Productimage string `json:"productimage,omitempty"`
|
Productimage string `json:"productimage,omitempty"`
|
||||||
Productdesc string `json:"productdesc,omitempty"`
|
Productdesc string `json:"productdesc,omitempty"`
|
||||||
Productsku string `json:"productsku,omitempty"`
|
Productsku string `json:"productsku,omitempty"`
|
||||||
|
|
||||||
|
// Three columns this read used to leave in the table.
|
||||||
|
//
|
||||||
|
// All three are stored on `products` and none of them reached the store
|
||||||
|
// catalogue screen, because this struct had no field to scan them into —
|
||||||
|
// so the console could not use what the import had gone to the trouble of
|
||||||
|
// saving:
|
||||||
|
//
|
||||||
|
// Imageid the catalogue's durable key, and what HealthScorePanel
|
||||||
|
// joins on. Absent, the panel reads it as "this product
|
||||||
|
// never came from the catalogue" and renders nothing — for
|
||||||
|
// EVERY product, including ones that plainly did.
|
||||||
|
// Productimages the rest of a product's photos. `imagesOf()` parses this
|
||||||
|
// and always got undefined, so the gallery fell back to
|
||||||
|
// the single `productimage` and the extra images — 90 of
|
||||||
|
// nestle's 123 products have them — were never shown.
|
||||||
|
// Cataloguefacts the licence, nutrition, highlights, providers and price
|
||||||
|
// range kept at import so they survive a re-scrape.
|
||||||
|
Imageid string `json:"imageid,omitempty"`
|
||||||
|
Productimages string `json:"productimages,omitempty"`
|
||||||
|
Cataloguefacts string `json:"cataloguefacts,omitempty"`
|
||||||
|
|
||||||
Brandid int `json:"brandid,omitempty"`
|
Brandid int `json:"brandid,omitempty"`
|
||||||
Productbrand string `json:"productbrand,omitempty"`
|
Productbrand string `json:"productbrand,omitempty"`
|
||||||
Productunit string `json:"productunit"`
|
Productunit string `json:"productunit"`
|
||||||
|
|||||||
@@ -11,8 +11,16 @@ package models
|
|||||||
type ScanLookupRequest struct {
|
type ScanLookupRequest struct {
|
||||||
Customerid int `json:"customerid"`
|
Customerid int `json:"customerid"`
|
||||||
// What Lens read: "Milk Bikis", "Dabur Honey 500g". Free text, trimmed
|
// What Lens read: "Milk Bikis", "Dabur Honey 500g". Free text, trimmed
|
||||||
// and capped by the service.
|
// and capped by the service. Not required when Brand and Catalogueid
|
||||||
|
// name a product outright.
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
|
// A product the customer has already chosen, by its catalogue key —
|
||||||
|
// which is how the app resolves a `candidates` list from an earlier
|
||||||
|
// ambiguous lookup, and how a deep link or a re-order skips recognition
|
||||||
|
// altogether. When both are set the label is ignored and no catalogue
|
||||||
|
// search runs.
|
||||||
|
Brand string `json:"brand"`
|
||||||
|
Catalogueid int64 `json:"catalogueid"`
|
||||||
// Where the customer is right now. Optional: without it the customer's
|
// Where the customer is right now. Optional: without it the customer's
|
||||||
// saved primary address is used, and without that stores are listed in
|
// saved primary address is used, and without that stores are listed in
|
||||||
// registration order with no distance.
|
// registration order with no distance.
|
||||||
@@ -86,9 +94,15 @@ type ScanCatalogueMatch struct {
|
|||||||
VariantKey string `json:"variant_key,omitempty"`
|
VariantKey string `json:"variant_key,omitempty"`
|
||||||
Image string `json:"image,omitempty"`
|
Image string `json:"image,omitempty"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
// "vector", "vector+text" or "text" — how the score was produced. The app
|
// "vector+text", "text" or "direct" — how the score was produced. The app
|
||||||
// can be more cautious with a text-only match.
|
// can be more cautious with a text-only match; "direct" means the caller
|
||||||
|
// named the product by its catalogue key and nothing was recognised.
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
|
// Set only on entries of `candidates`: at least one of the customer's
|
||||||
|
// registered stores has this product in stock right now. Candidates are
|
||||||
|
// ordered with the available ones first, so a "did you mean?" list can
|
||||||
|
// show what is actually buyable before what is not.
|
||||||
|
Available bool `json:"available,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScanLookupResponse is the answer to a scan.
|
// ScanLookupResponse is the answer to a scan.
|
||||||
@@ -96,10 +110,27 @@ type ScanLookupResponse struct {
|
|||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
// The best catalogue product for the label, and the sizes of it the
|
// The best catalogue product for the label, and the sizes of it the
|
||||||
// catalogue knows about (each a separate catalogue row).
|
// catalogue knows about (each a separate catalogue row).
|
||||||
|
//
|
||||||
|
// Match is nil when nothing was recognised, and also when several
|
||||||
|
// products matched equally well — see Ambiguous.
|
||||||
Match *ScanCatalogueMatch `json:"match"`
|
Match *ScanCatalogueMatch `json:"match"`
|
||||||
Variants []ScanCatalogueMatch `json:"catalogue_variants"`
|
Variants []ScanCatalogueMatch `json:"catalogue_variants"`
|
||||||
|
// Several products fit the label and no one of them is a clear winner —
|
||||||
|
// which is what a bare brand name ("britannia") or a generic word
|
||||||
|
// ("biscuits") produces, and Lens returns those often because a
|
||||||
|
// wordmark is the most legible thing on a packet.
|
||||||
|
//
|
||||||
|
// When true: Match is nil, Stores is empty, and Candidates holds the
|
||||||
|
// products to offer as "did you mean?". Picking one means calling
|
||||||
|
// /lookup again with that candidate's `brand` and `catalogueid`.
|
||||||
|
//
|
||||||
|
// Guessing instead would mean showing a confident price for a product
|
||||||
|
// the customer did not photograph.
|
||||||
|
Ambiguous bool `json:"ambiguous"`
|
||||||
|
Candidates []ScanCatalogueMatch `json:"candidates"`
|
||||||
// 0..1. Below ~0.5 the app should confirm with the customer before
|
// 0..1. Below ~0.5 the app should confirm with the customer before
|
||||||
// showing prices.
|
// showing prices. With Ambiguous set this is the leader's score, which
|
||||||
|
// by definition the runner-up nearly equals.
|
||||||
Confidence float64 `json:"confidence"`
|
Confidence float64 `json:"confidence"`
|
||||||
// Registered stores that stock the product, nearest first, in-stock
|
// Registered stores that stock the product, nearest first, in-stock
|
||||||
// first. Empty with Available=false when none does.
|
// first. Empty with Available=false when none does.
|
||||||
|
|||||||
@@ -71,6 +71,14 @@ type Tenantinfo struct {
|
|||||||
Allocationid int `json:"allocationid"`
|
Allocationid int `json:"allocationid"`
|
||||||
Allocationtype string `json:"allocationtype"`
|
Allocationtype string `json:"allocationtype"`
|
||||||
Allocationmode int `json:"allocationmode"`
|
Allocationmode int `json:"allocationmode"`
|
||||||
|
|
||||||
|
// How many outlets this merchant has.
|
||||||
|
//
|
||||||
|
// Only `GetAllTenants` fills this; it is 0 everywhere else, which is why it
|
||||||
|
// is last and optional rather than part of the record proper. The console's
|
||||||
|
// store list previously derived it by counting duplicate rows, and this
|
||||||
|
// endpoint has never returned duplicates — see the note on the query.
|
||||||
|
Branchcount int `json:"branchcount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tenantlocations struct {
|
type Tenantlocations struct {
|
||||||
@@ -190,6 +198,10 @@ type StaffInfo struct {
|
|||||||
Tenantid int `json:"tenantid"`
|
Tenantid int `json:"tenantid"`
|
||||||
Locationid int `json:"locationid"`
|
Locationid int `json:"locationid"`
|
||||||
Locationname string `json:"locationname"`
|
Locationname string `json:"locationname"`
|
||||||
|
// Whether this login still works, straight off `app_users.status`.
|
||||||
|
// Without it every row on the console's Users & access screen read
|
||||||
|
// "Unknown", because the field was never selected or sent.
|
||||||
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tenantuser struct {
|
type Tenantuser struct {
|
||||||
|
|||||||
@@ -208,3 +208,35 @@ func TestNormaliseBrandKeyRefusesToInventAKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every word of the label was once required, so one word the catalogue does
|
||||||
|
// not use ("Parle G biscuit pack") kept the right product out of the result
|
||||||
|
// altogether and left the vector search to answer alone.
|
||||||
|
func TestMinTokenHitsAsksForMostWordsNotAllOfThem(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ tokens, want int }{
|
||||||
|
{1, 1}, // one word: it has to be there
|
||||||
|
{2, 2}, // "Parle G" — both, and both are in Parle-G
|
||||||
|
{3, 2}, // "Milk Bikis pack" — the pack is allowed to be missing
|
||||||
|
{4, 3}, // "Parle G biscuit pack"
|
||||||
|
{5, 4},
|
||||||
|
{6, 4},
|
||||||
|
} {
|
||||||
|
if got := minTokenHits(tc.tokens); got != tc.want {
|
||||||
|
t.Errorf("%d tokens: need %d, want %d", tc.tokens, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A threshold that could fall to 1 would let any single common word drag in
|
||||||
|
// whole brand tables; one that stayed at n would be the bug all over again.
|
||||||
|
func TestMinTokenHitsStaysBetweenTwoAndAll(t *testing.T) {
|
||||||
|
for n := 3; n <= 30; n++ {
|
||||||
|
got := minTokenHits(n)
|
||||||
|
if got < 2 {
|
||||||
|
t.Fatalf("%d tokens: %d is too loose", n, got)
|
||||||
|
}
|
||||||
|
if got >= n {
|
||||||
|
t.Fatalf("%d tokens: %d still demands every word", n, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,30 +87,67 @@ func (r *partnerRepository) GetPartners(aid, pid, uid int) ([]models.Partnerinfo
|
|||||||
var q1 string
|
var q1 string
|
||||||
var args []interface{}
|
var args []interface{}
|
||||||
|
|
||||||
|
// Every variant joins partnerlocations, and that join is the whole point.
|
||||||
|
//
|
||||||
|
// ── It is what separates our partners from somebody else's ──────────────
|
||||||
|
//
|
||||||
|
// `partnerinfo` is shared. It has no column saying which product a row
|
||||||
|
// belongs to — no configid, no appid — so a partner created by another app
|
||||||
|
// on this database is indistinguishable from ours by its own fields, and
|
||||||
|
// this read used to return every Active row on the platform. The console
|
||||||
|
// made that worse rather than better: it asks `getapplocations` for EVERY
|
||||||
|
// region and then fetches partners region by region, so the applocationid
|
||||||
|
// filter below never narrowed anything.
|
||||||
|
//
|
||||||
|
// `partnerlocations` is the difference. Only `CreatePartner` writes it —
|
||||||
|
// one row per region, in the same transaction as the partner — so a row in
|
||||||
|
// that table means "registered through this console". The partners that
|
||||||
|
// predate it were inserted by hand and have none, which is why two of them
|
||||||
|
// are called "Test".
|
||||||
|
//
|
||||||
|
// ── The region filter reads the link table, not the home region ─────────
|
||||||
|
//
|
||||||
|
// `partnerinfo.applocationid` is the HOME region — CreatePartner writes
|
||||||
|
// `regions[0]` there — while partnerlocations holds every region covered.
|
||||||
|
// Those are not the same thing, and not only in theory: partner 44,
|
||||||
|
// Xpress-Cbe-Main, has a home region of 1 and link rows for 1 AND 2, so
|
||||||
|
// filtering on the partner row hid them from every Madurai query. That is
|
||||||
|
// the case the link table exists for.
|
||||||
|
//
|
||||||
|
// DISTINCT because such a partner has one row per region in the join and is
|
||||||
|
// still one partner. Only partnerinfo columns are selected, so there is
|
||||||
|
// nothing per-region for it to fail to collapse.
|
||||||
|
//
|
||||||
|
// A caller fanning out over regions and concatenating the answers still has
|
||||||
|
// to dedupe — the same partner is legitimately in two of them. The console's
|
||||||
|
// `useAllPartners` does; it listed Xpress-Cbe-Main twice until it did.
|
||||||
|
const columns = `select distinct p.partnerid,p.applocationid,p.partnertypeid,p.partnername,
|
||||||
|
p.primarycontact,p.primaryemail,p.contactno,p.address,p.suburb,p.state,p.city,p.partnerimage
|
||||||
|
from partnerinfo p
|
||||||
|
inner join partnerlocations l on l.partnerid = p.partnerid
|
||||||
|
where p.status='Active'`
|
||||||
|
|
||||||
if pid != 0 {
|
if pid != 0 {
|
||||||
q1 = `select partnerid,applocationid,partnertypeid,partnername,primarycontact,primaryemail,
|
// Scoped the same way on purpose: asking for a partner by id must not
|
||||||
contactno,address,suburb,state,city,partnerimage
|
// be a way round the separation above.
|
||||||
from partnerinfo where status='Active' and partnerid=?`
|
q1 = columns + ` and p.partnerid=?`
|
||||||
args = append(args, pid)
|
args = append(args, pid)
|
||||||
|
|
||||||
} else if aid != 0 {
|
} else if aid != 0 {
|
||||||
q1 = `select partnerid,applocationid,partnertypeid,partnername,primarycontact,primaryemail,
|
q1 = columns + ` and l.applocationid=?`
|
||||||
contactno,address,suburb,state,city,partnerimage
|
|
||||||
from partnerinfo where status='Active' and applocationid=?`
|
|
||||||
args = append(args, aid)
|
args = append(args, aid)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
q1 = `select partnerid,applocationid,partnertypeid,partnername,primarycontact,primaryemail,
|
q1 = columns
|
||||||
contactno,address,suburb,state,city,partnerimage
|
|
||||||
from partnerinfo where status='Active'`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
q1 += ` order by p.partnername, p.partnerid`
|
||||||
|
|
||||||
err := r.db.Raw(q1, args...).Find(&data).Error
|
err := r.db.Raw(q1, args...).Find(&data).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
print(q1)
|
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,13 +652,17 @@ them are named "Test".
|
|||||||
|
|
||||||
Where a partner works is recorded twice, on purpose and not by accident:
|
Where a partner works is recorded twice, on purpose and not by accident:
|
||||||
|
|
||||||
partnerinfo.applocationid their home region — `GetPartners` filters on it
|
partnerinfo.applocationid their home region — the rider app reads it
|
||||||
and the rider app reads it
|
|
||||||
partnerlocations every region they cover
|
partnerlocations every region they cover
|
||||||
|
|
||||||
Both are kept in step here. Writing only the first would confine a partner to
|
Both are kept in step here. Writing only the first would confine a partner to
|
||||||
one city, and writing only the second would hide them from every existing
|
one city, and writing only the second would hide them from the rider app.
|
||||||
query. */
|
|
||||||
|
`GetPartners` reads the SECOND: it joins partnerlocations, which both scopes a
|
||||||
|
region query to every city a partner actually covers and — because only this
|
||||||
|
function writes that table — separates partners registered here from the ones
|
||||||
|
another product put in the shared `partnerinfo`. So the link rows are not
|
||||||
|
bookkeeping; they are what makes a partner ours. */
|
||||||
|
|
||||||
// CreatePartner onboards a delivery partner and records the regions they cover.
|
// CreatePartner onboards a delivery partner and records the regions they cover.
|
||||||
func (r *partnerRepository) CreatePartner(input models.NewPartner) (int, error) {
|
func (r *partnerRepository) CreatePartner(input models.NewPartner) (int, error) {
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ type ProductRepository interface {
|
|||||||
UpdateProductStatus(productIDs []int, status string) error
|
UpdateProductStatus(productIDs []int, status string) error
|
||||||
SyncProductLocationStatus(refs []models.ProductLocationRef) error
|
SyncProductLocationStatus(refs []models.ProductLocationRef) error
|
||||||
EnsureProductLocation(refs []models.ProductLocationRef) error
|
EnsureProductLocation(refs []models.ProductLocationRef) error
|
||||||
CreateProduct(product models.Products) error
|
|
||||||
UpdateProduct(product models.Products) error
|
UpdateProduct(product models.Products) error
|
||||||
DeleteProduct(productID int) error
|
DeleteProduct(productID int) error
|
||||||
GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error)
|
GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error)
|
||||||
@@ -393,19 +392,32 @@ func (r *productRepository) UpdateProductStatus(productIDs []int, status string)
|
|||||||
Update("productstatus", status).Error
|
Update("productstatus", status).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *productRepository) CreateProduct(product models.Products) error {
|
// normaliseProductJSON makes a product safe to INSERT.
|
||||||
tx := r.db.Begin()
|
//
|
||||||
|
// `products.productimages` is jsonb and `models.Products.Productimages` is a
|
||||||
if err := tx.Create(&product).Error; err != nil {
|
// plain string, so a caller that never set it hands GORM the zero value — and
|
||||||
tx.Rollback()
|
// GORM puts that empty string in the INSERT rather than omitting the column.
|
||||||
return err
|
// Postgres answers "invalid input syntax for type json (SQLSTATE 22P02)" and
|
||||||
|
// the whole row is rejected, over a field nobody asked for.
|
||||||
|
//
|
||||||
|
// That was not a corner case: the console's sheet importer sends no
|
||||||
|
// productimages at all, so EVERY product it created failed with a 500, and
|
||||||
|
// ImportCatalogueProduct leaves the field empty for any catalogue product that
|
||||||
|
// has no photos. An empty ARRAY is the honest value — there are no extra
|
||||||
|
// images — and it is what `catalogueUploadService` already does for its own
|
||||||
|
// jsonb column, for the same reason.
|
||||||
|
//
|
||||||
|
// Applied at the one create path, which is the last point before the SQL, and
|
||||||
|
// the constraint being satisfied is the database's.
|
||||||
|
func normaliseProductJSON(product *models.Products) {
|
||||||
|
if strings.TrimSpace(product.Productimages) == "" {
|
||||||
|
product.Productimages = "[]"
|
||||||
}
|
}
|
||||||
|
// An OBJECT, not an array: this one holds named catalogue fields, and `{}`
|
||||||
if err := tx.Commit().Error; err != nil {
|
// is what a reader parsing it expects to find when there are none.
|
||||||
return err
|
if strings.TrimSpace(product.Cataloguefacts) == "" {
|
||||||
|
product.Cataloguefacts = "{}"
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *productRepository) UpdateProduct(product models.Products) error {
|
func (r *productRepository) UpdateProduct(product models.Products) error {
|
||||||
@@ -1316,10 +1328,22 @@ func (r *productRepository) FindTenantProductByCatalogueRef(tenantid int, brand
|
|||||||
return &product, nil
|
return &product, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateProductReturningID inserts a new product snapshot and returns its
|
// CreateProductReturningID inserts a product and returns its generated
|
||||||
// generated productid. Kept separate from CreateProduct so existing callers
|
// productid.
|
||||||
// of CreateProduct are unaffected.
|
//
|
||||||
|
// This is now the only way to create one. There used to be a second method,
|
||||||
|
// `CreateProduct`, that did the same INSERT and threw the id away — it took
|
||||||
|
// the struct by value, so GORM wrote the generated id onto a copy that went
|
||||||
|
// out of scope, and `POST /products/create` answered `productid: 0` for every
|
||||||
|
// product it had just created. The console worked around it by creating, then
|
||||||
|
// re-reading the whole tenant catalogue, then matching back by SKU.
|
||||||
|
//
|
||||||
|
// The two were kept apart so that "existing callers are unaffected", but the
|
||||||
|
// only caller of the id-less one was the endpoint that needed the id most.
|
||||||
|
// One create path also means the jsonb guard above has one place to live.
|
||||||
func (r *productRepository) CreateProductReturningID(product models.Products) (int, error) {
|
func (r *productRepository) CreateProductReturningID(product models.Products) (int, error) {
|
||||||
|
normaliseProductJSON(&product)
|
||||||
|
|
||||||
if err := r.db.Create(&product).Error; err != nil {
|
if err := r.db.Create(&product).Error; err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ type ScanRepository interface {
|
|||||||
VectorSearch(ctx context.Context, vector []float32, limit int) ([]CatalogueHit, error)
|
VectorSearch(ctx context.Context, vector []float32, limit int) ([]CatalogueHit, error)
|
||||||
TextSearch(ctx context.Context, label string, limit int) ([]CatalogueHit, error)
|
TextSearch(ctx context.Context, label string, limit int) ([]CatalogueHit, error)
|
||||||
VectorSearchAvailable() bool
|
VectorSearchAvailable() bool
|
||||||
|
// CatalogueRef is one product named by its catalogue key, with its other
|
||||||
|
// pack sizes after it. Nothing is recognised or scored.
|
||||||
|
CatalogueRef(ctx context.Context, brand string, id int64) ([]CatalogueHit, error)
|
||||||
|
|
||||||
// cache
|
// cache
|
||||||
CachedVector(ctx context.Context, model, label string) ([]float32, bool)
|
CachedVector(ctx context.Context, model, label string) ([]float32, bool)
|
||||||
@@ -467,9 +470,22 @@ func (r *scanRepository) VectorSearch(ctx context.Context, vector []float32, lim
|
|||||||
return hits, nil
|
return hits, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// minTokenHits is how many of the label's words a row must carry to be worth
|
||||||
|
// looking at. Every word was once required, which meant a single word the
|
||||||
|
// catalogue does not use — "Parle G biscuit pack", "Milk Bikis pack" — kept
|
||||||
|
// the right product out of the result entirely, leaving the vector search to
|
||||||
|
// answer alone and confidently wrong. Most of them is enough; scoring sorts
|
||||||
|
// out the rest.
|
||||||
|
func minTokenHits(n int) int {
|
||||||
|
if n <= 2 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return (n*2 + 2) / 3 // two thirds, rounded up; never below 2 for n >= 3
|
||||||
|
}
|
||||||
|
|
||||||
// TextSearch is the fallback when there is no embedder, and the tie-breaker
|
// TextSearch is the fallback when there is no embedder, and the tie-breaker
|
||||||
// beside it when there is: rows whose name or title contains the label, or
|
// beside it when there is: rows whose name or title contains the label, or
|
||||||
// contains every word of it.
|
// carry most of its words.
|
||||||
func (r *scanRepository) TextSearch(ctx context.Context, label string, limit int) ([]CatalogueHit, error) {
|
func (r *scanRepository) TextSearch(ctx context.Context, label string, limit int) ([]CatalogueHit, error) {
|
||||||
tables, err := r.brandTables(ctx)
|
tables, err := r.brandTables(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -495,18 +511,32 @@ func (r *scanRepository) TextSearch(ctx context.Context, label string, limit int
|
|||||||
hay = "LOWER(COALESCE(product_name, '') || ' ' || COALESCE(title, '') || ' ' || COALESCE(search_query, ''))"
|
hay = "LOWER(COALESCE(product_name, '') || ' ' || COALESCE(title, '') || ' ' || COALESCE(search_query, ''))"
|
||||||
}
|
}
|
||||||
|
|
||||||
conds := []string{hay + " LIKE ?"}
|
// How well a row matches, as a number: the whole label as a substring
|
||||||
args = append(args, "%"+label+"%")
|
// outweighs any number of loose words, then one point per word found.
|
||||||
all := make([]string, 0, len(tokens))
|
hits := make([]string, 0, len(tokens)+1)
|
||||||
for _, tok := range tokens {
|
hits = append(hits, "(CASE WHEN "+hay+" LIKE ? THEN 100 ELSE 0 END)")
|
||||||
all = append(all, hay+" LIKE ?")
|
for range tokens {
|
||||||
args = append(args, "%"+tok+"%")
|
hits = append(hits, "(CASE WHEN "+hay+" LIKE ? THEN 1 ELSE 0 END)")
|
||||||
}
|
}
|
||||||
conds = append(conds, "("+strings.Join(all, " AND ")+")")
|
rank := strings.Join(hits, " + ")
|
||||||
|
|
||||||
|
// The expression appears twice in the SQL — once to filter, once to
|
||||||
|
// order — so its arguments are bound twice, in that order.
|
||||||
|
bind := func() {
|
||||||
|
args = append(args, "%"+label+"%")
|
||||||
|
for _, tok := range tokens {
|
||||||
|
args = append(args, "%"+tok+"%")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bind()
|
||||||
|
bind()
|
||||||
|
|
||||||
|
// Ordering matters as much as the threshold: a looser WHERE lets more
|
||||||
|
// rows qualify, and an unordered LIMIT would then be free to return
|
||||||
|
// the wrong ones. Best match per brand first, id to keep it stable.
|
||||||
branches = append(branches, fmt.Sprintf(
|
branches = append(branches, fmt.Sprintf(
|
||||||
`(SELECT %s, -1::float8 AS distance FROM %s WHERE %s LIMIT %d)`,
|
`(SELECT %s, -1::float8 AS distance FROM %s WHERE (%s) >= %d ORDER BY (%s) DESC, id LIMIT %d)`,
|
||||||
hitColumns(brand, cols), table, strings.Join(conds, " OR "), limit))
|
hitColumns(brand, cols), table, rank, minTokenHits(len(tokens)), rank, limit))
|
||||||
}
|
}
|
||||||
if len(branches) == 0 {
|
if len(branches) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -519,6 +549,72 @@ func (r *scanRepository) TextSearch(ctx context.Context, label string, limit int
|
|||||||
return hits, nil
|
return hits, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tableFor resolves a brand the caller named to a real catalogue table.
|
||||||
|
//
|
||||||
|
// The lookup is against the tables discovered from information_schema, never
|
||||||
|
// a string built from the request: table names cannot be parameterised in
|
||||||
|
// SQL, so the discovered map is what keeps this from being an injection
|
||||||
|
// point. Both the table suffix ("britannia") and a display name ("24 Mantra"
|
||||||
|
// → brand_24_mantra) resolve.
|
||||||
|
func (r *scanRepository) tableFor(ctx context.Context, brand string) (string, map[string]bool, error) {
|
||||||
|
tables, err := r.brandTables(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
for _, candidate := range []string{
|
||||||
|
"brand_" + strings.ToLower(strings.TrimSpace(brand)),
|
||||||
|
"brand_" + normaliseBrandKey(brand),
|
||||||
|
} {
|
||||||
|
if cols, ok := tables[candidate]; ok {
|
||||||
|
return candidate, cols, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil, ErrUnknownBrand
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogueRef reads one product by (brand, id) and appends its other pack
|
||||||
|
// sizes — same variant_key where the catalogue assigned one, same name
|
||||||
|
// otherwise, matching how the search groups a family.
|
||||||
|
//
|
||||||
|
// Distance is 0 on every row: nothing here was ranked, the caller said which
|
||||||
|
// product they meant.
|
||||||
|
func (r *scanRepository) CatalogueRef(ctx context.Context, brand string, id int64) ([]CatalogueHit, error) {
|
||||||
|
table, cols, err := r.tableFor(ctx, brand)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
suffix := strings.TrimPrefix(table, "brand_")
|
||||||
|
columns := hitColumns(suffix, cols)
|
||||||
|
|
||||||
|
var self []CatalogueHit
|
||||||
|
err = r.catalogue.WithContext(ctx).Raw(fmt.Sprintf(
|
||||||
|
`SELECT %s, 0::float8 AS distance FROM %s WHERE id = ?`, columns, table), id).Scan(&self).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(self) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var siblings []CatalogueHit
|
||||||
|
if cols["variant_key"] && strings.TrimSpace(self[0].VariantKey) != "" {
|
||||||
|
err = r.catalogue.WithContext(ctx).Raw(fmt.Sprintf(
|
||||||
|
`SELECT %s, 0::float8 AS distance FROM %s WHERE variant_key = ? AND id <> ? ORDER BY id`,
|
||||||
|
columns, table), self[0].VariantKey, id).Scan(&siblings).Error
|
||||||
|
} else {
|
||||||
|
err = r.catalogue.WithContext(ctx).Raw(fmt.Sprintf(
|
||||||
|
`SELECT %s, 0::float8 AS distance FROM %s WHERE LOWER(product_name) = LOWER(?) AND id <> ? ORDER BY id`,
|
||||||
|
columns, table), self[0].ProductName, id).Scan(&siblings).Error
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// The product itself was found; losing its other sizes is the smaller
|
||||||
|
// failure and the caller asked for this one.
|
||||||
|
log.Printf("scan: could not read pack sizes of %s#%d: %v", brand, id, err)
|
||||||
|
return self, nil
|
||||||
|
}
|
||||||
|
return append(self, siblings...), nil
|
||||||
|
}
|
||||||
|
|
||||||
func sortedKeys(m map[string]map[string]bool) []string {
|
func sortedKeys(m map[string]map[string]bool) []string {
|
||||||
keys := make([]string, 0, len(m))
|
keys := make([]string, 0, len(m))
|
||||||
for k := range m {
|
for k := range m {
|
||||||
|
|||||||
@@ -85,7 +85,22 @@ func (r *tenantRepository) GetAllTenants(pageno, pagesize, aid int, status, tena
|
|||||||
|
|
||||||
var data []models.Tenantinfo
|
var data []models.Tenantinfo
|
||||||
|
|
||||||
base := `SELECT * FROM tenants a WHERE 1 = 1`
|
// `branchcount` is selected here because there is nowhere else to get it.
|
||||||
|
//
|
||||||
|
// This returns one row per TENANT — there is no join to tenantlocations at
|
||||||
|
// all — but the console's store list read it as one row per
|
||||||
|
// tenant-location pair and counted the duplicates, so every merchant on the
|
||||||
|
// platform showed exactly one branch, and the "Branches" and "Avg branches"
|
||||||
|
// tiles above the list were the tenant count wearing another name. The
|
||||||
|
// tenant's own detail page, which reads gettenantlocations, disagreed with
|
||||||
|
// the list it was opened from.
|
||||||
|
//
|
||||||
|
// A correlated subquery rather than a LEFT JOIN + GROUP BY: the row shape
|
||||||
|
// stays exactly as it was, so nothing else that reads this endpoint has to
|
||||||
|
// change, and every filter below still applies to `a` alone.
|
||||||
|
base := `SELECT a.*,
|
||||||
|
(SELECT COUNT(*) FROM tenantlocations tl WHERE tl.tenantid = a.tenantid) AS branchcount
|
||||||
|
FROM tenants a WHERE 1 = 1`
|
||||||
|
|
||||||
var (
|
var (
|
||||||
conds []string
|
conds []string
|
||||||
@@ -337,7 +352,12 @@ func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) {
|
|||||||
a.state,a.postcode,a.userfcmtoken,a.pin,a.applocationid,
|
a.state,a.postcode,a.userfcmtoken,a.pin,a.applocationid,
|
||||||
a.roleid,a.partnerid,a.tenantid,a.locationid,
|
a.roleid,a.partnerid,a.tenantid,a.locationid,
|
||||||
b.locationname,
|
b.locationname,
|
||||||
COALESCE(c.rolename,'') AS rolename
|
COALESCE(c.rolename,'') AS rolename,
|
||||||
|
-- Whether the account still works. Absent from this SELECT
|
||||||
|
-- until now, so Users & access had nothing to read and showed
|
||||||
|
-- every person on the platform as "Unknown" — an admin could not
|
||||||
|
-- tell a working login from one that had been switched off.
|
||||||
|
COALESCE(a.status,'') AS status
|
||||||
FROM app_users a
|
FROM app_users a
|
||||||
LEFT JOIN tenantlocations b ON a.locationid = b.locationid
|
LEFT JOIN tenantlocations b ON a.locationid = b.locationid
|
||||||
LEFT JOIN app_roles c ON c.roleid = a.roleid
|
LEFT JOIN app_roles c ON c.roleid = a.roleid
|
||||||
@@ -625,6 +645,51 @@ func (r *tenantRepository) CreateTenantUser(data models.Tenants) (bool, error) {
|
|||||||
var custloc models.Customerlocations
|
var custloc models.Customerlocations
|
||||||
var tcust models.Tenantcustomers
|
var tcust models.Tenantcustomers
|
||||||
|
|
||||||
|
// A tenant with configid 0 is unreachable, and it takes its customer row
|
||||||
|
// with it.
|
||||||
|
//
|
||||||
|
// Step 3 below already forces `user.Configid = 1`, with a comment
|
||||||
|
// explaining that AppLogin only ever queries configid 1 and a zero makes
|
||||||
|
// the account permanently unfindable. The same zero was left to flow into
|
||||||
|
// `tenants` itself and into the `customers` row copied from it at step 4,
|
||||||
|
// where nothing corrected it — so a caller that omits configid (the console
|
||||||
|
// sends it; the mobile route and anything else need not) created a business
|
||||||
|
// and a customer that no scoped read can see.
|
||||||
|
//
|
||||||
|
// Defaulted rather than rejected: 1 is the only value any caller has ever
|
||||||
|
// meant here, and refusing the create would break callers that work today.
|
||||||
|
if data.Configid == 0 {
|
||||||
|
data.Configid = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the primary outlet the scaffolding the tenant already has.
|
||||||
|
//
|
||||||
|
// The outlet itself is created by GORM, as the `Tenantlocations`
|
||||||
|
// association on the struct below — the console nests a full object in the
|
||||||
|
// request and step 1 saves it with the tenant. What it does NOT do is fill
|
||||||
|
// anything the caller left out, and two of those columns matter:
|
||||||
|
//
|
||||||
|
// applocationid — `orderRepository.go` calls it "authoritative" and has
|
||||||
|
// no fallback anywhere for a 0.
|
||||||
|
// moduleid — same file: "tenantlocations carries 0 for
|
||||||
|
// moduleid/partnerid at outlets whose live orders
|
||||||
|
// nonetheless use non-zero values", worked around there
|
||||||
|
// by copying scaffolding off the most recent real order.
|
||||||
|
// A shop commissioned a minute ago has no such order.
|
||||||
|
//
|
||||||
|
// Neither column has a database default, and no onboarding form asks for
|
||||||
|
// them — they describe the platform, not the shop. The tenant's own values
|
||||||
|
// are the right answer and are already right here.
|
||||||
|
//
|
||||||
|
// Filled before the insert rather than corrected after it, so there is one
|
||||||
|
// write and no window where the row exists with a zero in it.
|
||||||
|
if data.Tenantlocations.Applocationid == 0 {
|
||||||
|
data.Tenantlocations.Applocationid = data.Applocationid
|
||||||
|
}
|
||||||
|
if data.Tenantlocations.Moduleid == 0 {
|
||||||
|
data.Tenantlocations.Moduleid = data.Moduleid
|
||||||
|
}
|
||||||
|
|
||||||
tx := r.db.Begin()
|
tx := r.db.Begin()
|
||||||
|
|
||||||
// Step 1: Insert into tenants
|
// Step 1: Insert into tenants
|
||||||
|
|||||||
@@ -255,6 +255,30 @@ func (r *userRepository) GetTenantUserById(userid int) models.TenantUserInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *userRepository) CreateUser(user models.User) (int, error) {
|
func (r *userRepository) CreateUser(user models.User) (int, error) {
|
||||||
|
// Inherit the delivery region from the tenant when the caller did not name
|
||||||
|
// one.
|
||||||
|
//
|
||||||
|
// `app_users.applocationid` has no column default, and no console form
|
||||||
|
// collects it — it is a platform region, not something a merchant picks
|
||||||
|
// per person. So every back-office account created through this path landed
|
||||||
|
// with 0, which is not a region: `orderRepository.go` calls the equivalent
|
||||||
|
// column on tenantlocations "authoritative" and has no fallback for a zero,
|
||||||
|
// and 43 of 75 live branches are already in that state.
|
||||||
|
//
|
||||||
|
// A lookup rather than a default value, because the right answer is
|
||||||
|
// whichever region the business trades in. Failure is not fatal: the
|
||||||
|
// account is still worth creating, and a 0 here is exactly what would have
|
||||||
|
// been written anyway.
|
||||||
|
if user.Applocationid == 0 && user.Tenantid > 0 {
|
||||||
|
var inherited int
|
||||||
|
if err := r.db.Raw(
|
||||||
|
`SELECT COALESCE(applocationid, 0) FROM tenants WHERE tenantid = ?`,
|
||||||
|
user.Tenantid,
|
||||||
|
).Scan(&inherited).Error; err == nil && inherited > 0 {
|
||||||
|
user.Applocationid = inherited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tx := r.db.Begin()
|
tx := r.db.Begin()
|
||||||
|
|
||||||
if err := tx.Table("app_users").Create(&user).Error; err != nil {
|
if err := tx.Table("app_users").Create(&user).Error; err != nil {
|
||||||
|
|||||||
@@ -53,28 +53,29 @@ func main() {
|
|||||||
|
|
||||||
var cols []struct {
|
var cols []struct {
|
||||||
Relname string
|
Relname string
|
||||||
|
Attname string
|
||||||
Typname string
|
Typname string
|
||||||
Atttypmod int
|
Atttypmod int
|
||||||
}
|
}
|
||||||
if err := db.Raw(`
|
if err := db.Raw(`
|
||||||
SELECT c.relname, t.typname, a.atttypmod
|
SELECT c.relname, a.attname, t.typname, a.atttypmod
|
||||||
FROM pg_attribute a
|
FROM pg_attribute a
|
||||||
JOIN pg_class c ON c.oid = a.attrelid
|
JOIN pg_class c ON c.oid = a.attrelid
|
||||||
JOIN pg_type t ON t.oid = a.atttypid
|
JOIN pg_type t ON t.oid = a.atttypid
|
||||||
WHERE a.attname = 'embedding' AND c.relname LIKE 'brand\_%'
|
WHERE t.typname = 'vector' AND a.attnum > 0 AND c.relname LIKE 'brand\_%'
|
||||||
ORDER BY c.relname`).Scan(&cols).Error; err != nil {
|
ORDER BY c.relname, a.attname`).Scan(&cols).Error; err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(cols) == 0 {
|
if len(cols) == 0 {
|
||||||
fmt.Println("no brand_* table has an embedding column")
|
fmt.Println("no brand_* table has a vector column")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Printf("%-28s %-8s %5s %5s %5s\n", "table", "type", "dims", "rows", "embd")
|
fmt.Printf("%-24s %-16s %-8s %5s %5s %5s\n", "table", "column", "type", "dims", "rows", "filled")
|
||||||
for _, c := range cols {
|
for _, c := range cols {
|
||||||
var total, filled int64
|
var total, filled int64
|
||||||
db.Raw(fmt.Sprintf(`SELECT COUNT(1) FROM %s`, c.Relname)).Scan(&total)
|
db.Raw(fmt.Sprintf(`SELECT COUNT(1) FROM %s`, c.Relname)).Scan(&total)
|
||||||
db.Raw(fmt.Sprintf(`SELECT COUNT(1) FROM %s WHERE embedding IS NOT NULL`, c.Relname)).Scan(&filled)
|
db.Raw(fmt.Sprintf(`SELECT COUNT(1) FROM %s WHERE %s IS NOT NULL`, c.Relname, c.Attname)).Scan(&filled)
|
||||||
fmt.Printf("%-28s %-8s %5d %5d %5d\n", c.Relname, c.Typname, c.Atttypmod, total, filled)
|
fmt.Printf("%-24s %-16s %-8s %5d %5d %5d\n", c.Relname, c.Attname, c.Typname, c.Atttypmod, total, filled)
|
||||||
}
|
}
|
||||||
|
|
||||||
// nomic/bge emit unit vectors; a norm far from 1 means another pipeline.
|
// nomic/bge emit unit vectors; a norm far from 1 means another pipeline.
|
||||||
|
|||||||
402
scratch/cataloguefactsbackfill/main.go
Normal file
402
scratch/cataloguefactsbackfill/main.go
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
// Backfills products.cataloguefacts for products imported before the column existed.
|
||||||
|
//
|
||||||
|
// The catalogue import copied eight of the catalogue's eighteen fields onto a
|
||||||
|
// tenant's product and left the other ten behind — the FSSAI licence, nutrients,
|
||||||
|
// highlights, providers, the typical price range, the variant key. The console
|
||||||
|
// covered for it by asking the catalogue again on every drawer open, and that
|
||||||
|
// stops working the moment a re-scrape retires the source row: a tenant's
|
||||||
|
// product is a SNAPSHOT and outlives it, so a licence number came off a product
|
||||||
|
// the shop was still selling with no way back.
|
||||||
|
//
|
||||||
|
// The import keeps them now. Every product imported BEFORE that does not have
|
||||||
|
// them, and no amount of new code fixes a row that was written last month — so
|
||||||
|
// this reads each one's catalogue entry while it is still there and stores it.
|
||||||
|
//
|
||||||
|
// go run ./scratch/cataloguefactsbackfill # dry run — shows every change
|
||||||
|
// go run ./scratch/cataloguefactsbackfill apply # writes, then prints the undo
|
||||||
|
//
|
||||||
|
// ── What it will and will not touch ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Only products with an `imageid` and a NULL `cataloguefacts`. That is the
|
||||||
|
// whole safety story:
|
||||||
|
//
|
||||||
|
// - NULL means nothing was ever written. A product whose facts are already
|
||||||
|
// stored — including one stored as `{}` because the catalogue genuinely had
|
||||||
|
// nothing to say — is never overwritten, so re-running this is a no-op
|
||||||
|
// rather than a second opinion.
|
||||||
|
// - No `imageid` means it never came from the catalogue. Sheet-imported
|
||||||
|
// products have no entry to read and are left alone.
|
||||||
|
// - A catalogue row that has already been retired cannot be recovered by
|
||||||
|
// anything, here or later. Those are counted and named rather than written
|
||||||
|
// as empty, because `{}` would claim the catalogue said nothing when the
|
||||||
|
// truth is that nobody asked in time.
|
||||||
|
//
|
||||||
|
// Brand tables are discovered rather than assumed, and their columns are
|
||||||
|
// checked one by one before being selected: the catalogue is another team's
|
||||||
|
// scrape, brands appear between runs, and a table missing `nutrients` is a
|
||||||
|
// perfectly good catalogue of products. Demanding the full column set is the
|
||||||
|
// exact mistake that once made 16 of 35 live brands invisible to this side.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The columns worth keeping, in the order the drawer reads them. Scalars and
|
||||||
|
// arrays are separated because an array comes back as a Postgres text[] literal
|
||||||
|
// and has to be parsed before it can be re-encoded as JSON.
|
||||||
|
var scalarFacts = []string{
|
||||||
|
"title", "category", "variant_key", "sku_source",
|
||||||
|
"price_range", "fssai_license", "search_query",
|
||||||
|
}
|
||||||
|
|
||||||
|
var arrayFacts = []string{"providers", "highlights", "nutrients"}
|
||||||
|
|
||||||
|
type product struct {
|
||||||
|
Productid int
|
||||||
|
Productbrand string
|
||||||
|
Imageid string
|
||||||
|
Productname string
|
||||||
|
Tenantid int
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
apply := len(os.Args) > 1 && os.Args[1] == "apply"
|
||||||
|
|
||||||
|
_ = godotenv.Load()
|
||||||
|
|
||||||
|
main, err := open("DB_HOST", "DB_PORT", "DB_USER", "DB_PASSWORD", "DB_NAME")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("nearledb: ", err)
|
||||||
|
}
|
||||||
|
cat, err := open("CATALOGUE_DB_HOST", "CATALOGUE_DB_PORT", "CATALOGUE_DB_USER",
|
||||||
|
"CATALOGUE_DB_PASSWORD", "CATALOGUE_DB_NAME")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("cataloguedb: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The column has to exist before there is anything to fill. Checked rather
|
||||||
|
// than assumed so this says so plainly instead of failing inside a query.
|
||||||
|
var hasColumn int
|
||||||
|
main.Raw(`SELECT COUNT(*) FROM information_schema.columns
|
||||||
|
WHERE table_name = 'products' AND column_name = 'cataloguefacts'`).Scan(&hasColumn)
|
||||||
|
if hasColumn == 0 {
|
||||||
|
log.Fatal("products.cataloguefacts does not exist — start the API once to run the migration, then re-run this")
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates []product
|
||||||
|
main.Raw(`SELECT productid, tenantid, COALESCE(productbrand,'') AS productbrand,
|
||||||
|
COALESCE(imageid,'') AS imageid, COALESCE(productname,'') AS productname
|
||||||
|
FROM products
|
||||||
|
WHERE COALESCE(imageid,'') <> '' AND cataloguefacts IS NULL
|
||||||
|
ORDER BY productbrand, productid`).Scan(&candidates)
|
||||||
|
|
||||||
|
var (
|
||||||
|
total int
|
||||||
|
alreadyDone int
|
||||||
|
noImageid int
|
||||||
|
)
|
||||||
|
main.Raw(`SELECT COUNT(*) FROM products`).Scan(&total)
|
||||||
|
main.Raw(`SELECT COUNT(*) FROM products WHERE cataloguefacts IS NOT NULL`).Scan(&alreadyDone)
|
||||||
|
main.Raw(`SELECT COUNT(*) FROM products WHERE COALESCE(imageid,'') = ''`).Scan(&noImageid)
|
||||||
|
|
||||||
|
fmt.Printf("products on the platform : %d\n", total)
|
||||||
|
fmt.Printf(" never came from the catalogue : %d (no imageid — left alone)\n", noImageid)
|
||||||
|
fmt.Printf(" facts already stored : %d (never overwritten)\n", alreadyDone)
|
||||||
|
fmt.Printf(" to backfill : %d\n\n", len(candidates))
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
fmt.Println("nothing to do.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// One column check per brand table, not per product: the shape is a
|
||||||
|
// property of the table and a per-row check would be thousands of
|
||||||
|
// information_schema reads to learn the same thing.
|
||||||
|
columnsByTable := map[string][]string{}
|
||||||
|
missingTable := map[string]bool{}
|
||||||
|
|
||||||
|
type update struct {
|
||||||
|
product product
|
||||||
|
facts string
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
updates []update
|
||||||
|
retired []product
|
||||||
|
unknown []product
|
||||||
|
emptyOnly []product
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, p := range candidates {
|
||||||
|
table := brandTable(p.Productbrand)
|
||||||
|
if table == "" {
|
||||||
|
unknown = append(unknown, p)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if missingTable[table] {
|
||||||
|
retired = append(retired, p)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cols, known := columnsByTable[table]
|
||||||
|
if !known {
|
||||||
|
cols = factColumnsOf(cat, table)
|
||||||
|
if cols == nil {
|
||||||
|
missingTable[table] = true
|
||||||
|
retired = append(retired, p)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
columnsByTable[table] = cols
|
||||||
|
}
|
||||||
|
|
||||||
|
facts, found := factsFor(cat, table, cols, p.Imageid)
|
||||||
|
if !found {
|
||||||
|
retired = append(retired, p)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(facts) == 0 {
|
||||||
|
// The row is there and had nothing in these columns. Worth writing
|
||||||
|
// `{}` — it is the true answer and it stops the console asking the
|
||||||
|
// catalogue again on every open.
|
||||||
|
emptyOnly = append(emptyOnly, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(facts)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("could not encode facts for product %d: %v", p.Productid, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
updates = append(updates, update{product: p, facts: string(encoded)})
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%-9s %-14s %-22s %-34s %s\n", "product", "brand", "imageid", "name", "facts recovered")
|
||||||
|
for _, u := range updates {
|
||||||
|
var keys []string
|
||||||
|
var got map[string]any
|
||||||
|
_ = json.Unmarshal([]byte(u.facts), &got)
|
||||||
|
for k := range got {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
summary := strings.Join(keys, ",")
|
||||||
|
if summary == "" {
|
||||||
|
summary = "(catalogue row has none)"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-9d %-14s %-22s %-34s %s\n",
|
||||||
|
u.product.Productid, trim(u.product.Productbrand, 14), trim(u.product.Imageid, 22),
|
||||||
|
trim(u.product.Productname, 34), summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(retired) > 0 {
|
||||||
|
fmt.Printf("\n!! %d product(s) cannot be recovered — their catalogue row is gone:\n", len(retired))
|
||||||
|
for _, p := range retired {
|
||||||
|
fmt.Printf(" %-9d %-14s %-22s %s\n", p.Productid, trim(p.Productbrand, 14),
|
||||||
|
trim(p.Imageid, 22), trim(p.Productname, 40))
|
||||||
|
}
|
||||||
|
fmt.Println(" These are left NULL. The console falls back to the live lookup for them,")
|
||||||
|
fmt.Println(" which will also find nothing — the detail was lost before this ran.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(unknown) > 0 {
|
||||||
|
fmt.Printf("\n!! %d product(s) carry a brand with no table in the catalogue:\n", len(unknown))
|
||||||
|
for _, p := range unknown {
|
||||||
|
fmt.Printf(" %-9d %-14s %s\n", p.Productid, trim(p.Productbrand, 14), trim(p.Productname, 40))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nwill write %d product(s)", len(updates))
|
||||||
|
if len(emptyOnly) > 0 {
|
||||||
|
fmt.Printf(", %d of them as `{}` because the catalogue row carries none of these fields", len(emptyOnly))
|
||||||
|
}
|
||||||
|
fmt.Printf("; leaving %d NULL\n", len(retired)+len(unknown))
|
||||||
|
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !apply {
|
||||||
|
fmt.Println("\ndry run — nothing written. re-run with `apply` to write.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row at a time, each guarded by `cataloguefacts IS NULL` again.
|
||||||
|
// Between the read above and this write another import could have stored
|
||||||
|
// the real thing, and this must never be the one that overwrites it.
|
||||||
|
written := 0
|
||||||
|
ids := make([]int, 0, len(updates))
|
||||||
|
for _, u := range updates {
|
||||||
|
res := main.Exec(`UPDATE products SET cataloguefacts = ?::jsonb
|
||||||
|
WHERE productid = ? AND cataloguefacts IS NULL`,
|
||||||
|
u.facts, u.product.Productid)
|
||||||
|
if res.Error != nil {
|
||||||
|
log.Printf("product %d: %v", u.product.Productid, res.Error)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if res.RowsAffected > 0 {
|
||||||
|
written++
|
||||||
|
ids = append(ids, u.product.Productid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nwrote %d product(s)\n", written)
|
||||||
|
|
||||||
|
var stillNull int
|
||||||
|
main.Raw(`SELECT COUNT(*) FROM products
|
||||||
|
WHERE COALESCE(imageid,'') <> '' AND cataloguefacts IS NULL`).Scan(&stillNull)
|
||||||
|
fmt.Printf("catalogue-linked products still without facts: %d\n", stillNull)
|
||||||
|
|
||||||
|
if len(ids) > 0 {
|
||||||
|
fmt.Printf("\nundo:\n UPDATE products SET cataloguefacts = NULL WHERE productid IN (%s);\n",
|
||||||
|
joinInts(ids))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func open(hostKey, portKey, userKey, passKey, nameKey string) (*gorm.DB, error) {
|
||||||
|
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||||
|
os.Getenv(hostKey), os.Getenv(portKey), os.Getenv(userKey),
|
||||||
|
os.Getenv(passKey), os.Getenv(nameKey))
|
||||||
|
return gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// brandTable mirrors the repository's rule: a brand IS a `brand_<name>` table.
|
||||||
|
//
|
||||||
|
// Lowercased and stripped of anything that is not a letter, digit or
|
||||||
|
// underscore. The table name cannot be parameterized in SQL, so this is the
|
||||||
|
// one place it is built and it refuses to build anything else.
|
||||||
|
func brandTable(brand string) string {
|
||||||
|
cleaned := strings.Map(func(r rune) rune {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_':
|
||||||
|
return r
|
||||||
|
case r >= 'A' && r <= 'Z':
|
||||||
|
return r + 32
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}, strings.TrimSpace(brand))
|
||||||
|
|
||||||
|
if cleaned == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "brand_" + cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
// factColumnsOf returns which of the fact columns this brand table actually
|
||||||
|
// has, or nil when the table is not there at all.
|
||||||
|
func factColumnsOf(db *gorm.DB, table string) []string {
|
||||||
|
var have []string
|
||||||
|
db.Raw(`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = ?`, table).Scan(&have)
|
||||||
|
if len(have) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
present := map[string]bool{}
|
||||||
|
for _, c := range have {
|
||||||
|
present[c] = true
|
||||||
|
}
|
||||||
|
// image_id is how a product is found at all. Without it the table cannot
|
||||||
|
// answer the question, whatever else it holds.
|
||||||
|
if !present["image_id"] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var keep []string
|
||||||
|
for _, c := range append(append([]string{}, scalarFacts...), arrayFacts...) {
|
||||||
|
if present[c] {
|
||||||
|
keep = append(keep, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keep
|
||||||
|
}
|
||||||
|
|
||||||
|
// factsFor reads one catalogue row and returns only what it actually stated.
|
||||||
|
//
|
||||||
|
// An empty field is omitted rather than stored as "" or [], so a reader can
|
||||||
|
// tell "the catalogue did not say" from "the catalogue said none" — the drawer
|
||||||
|
// prints a row per fact and an empty string would print an empty row.
|
||||||
|
func factsFor(db *gorm.DB, table string, cols []string, imageID string) (map[string]any, bool) {
|
||||||
|
if len(cols) == 0 {
|
||||||
|
return map[string]any{}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
selects := make([]string, 0, len(cols))
|
||||||
|
for _, c := range cols {
|
||||||
|
if isArrayFact(c) {
|
||||||
|
selects = append(selects, c+"::text AS "+c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
selects = append(selects, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
row := map[string]any{}
|
||||||
|
res := db.Raw(`SELECT `+strings.Join(selects, ", ")+` FROM `+table+
|
||||||
|
` WHERE image_id = ? LIMIT 1`, imageID).Scan(&row)
|
||||||
|
if res.Error != nil || res.RowsAffected == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
facts := map[string]any{}
|
||||||
|
for _, c := range cols {
|
||||||
|
raw, ok := row[c]
|
||||||
|
if !ok || raw == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(fmt.Sprintf("%v", raw))
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isArrayFact(c) {
|
||||||
|
values := models.ParsePGArray(text)
|
||||||
|
kept := make([]string, 0, len(values))
|
||||||
|
for _, v := range values {
|
||||||
|
if t := strings.TrimSpace(v); t != "" {
|
||||||
|
kept = append(kept, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) > 0 {
|
||||||
|
facts[c] = kept
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
facts[c] = text
|
||||||
|
}
|
||||||
|
return facts, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isArrayFact(name string) bool {
|
||||||
|
for _, c := range arrayFacts {
|
||||||
|
if c == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func trim(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if n <= 1 {
|
||||||
|
return s[:n]
|
||||||
|
}
|
||||||
|
return s[:n-1] + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinInts(ids []int) string {
|
||||||
|
parts := make([]string, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
parts[i] = fmt.Sprint(id)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
"nearle/repositories"
|
"nearle/repositories"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProductService interface {
|
type ProductService interface {
|
||||||
@@ -22,7 +24,7 @@ type ProductService interface {
|
|||||||
RemoveProductVariant(tenantid, variantid int) error
|
RemoveProductVariant(tenantid, variantid int) error
|
||||||
VariantChildIDs(tenantid int) (map[int]bool, error)
|
VariantChildIDs(tenantid int) (map[int]bool, error)
|
||||||
CreateProductStock(stocks []models.Productstock) error
|
CreateProductStock(stocks []models.Productstock) error
|
||||||
CreateProduct(product models.Products) error
|
CreateProduct(product models.Products) (models.Products, error)
|
||||||
UpdateProduct(product models.Products) error
|
UpdateProduct(product models.Products) error
|
||||||
DeleteProduct(productID int) error
|
DeleteProduct(productID int) error
|
||||||
GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error)
|
GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error)
|
||||||
@@ -169,8 +171,27 @@ func (s *productService) UpdateProductStatus(productIDs []int, status string) er
|
|||||||
return s.repo.UpdateProductStatus(productIDs, status)
|
return s.repo.UpdateProductStatus(productIDs, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *productService) CreateProduct(product models.Products) error {
|
// CreateProduct stores one product and hands it back with its id filled in.
|
||||||
return s.repo.CreateProduct(product)
|
//
|
||||||
|
// It used to return only an error, and the id was lost on the way out: the
|
||||||
|
// repository took the struct by value, GORM wrote the generated productid onto
|
||||||
|
// that copy, and the copy was discarded — so the endpoint answered
|
||||||
|
// `productid: 0` for a row that certainly had one.
|
||||||
|
//
|
||||||
|
// The caller needs it. A product is not sellable until it has been priced at an
|
||||||
|
// outlet and stocked there, and both of those calls are keyed on productid, so
|
||||||
|
// every importer had to create, re-read the tenant's whole catalogue, and match
|
||||||
|
// its own rows back by SKU to carry on — which is also why creating two
|
||||||
|
// products with the same SKU quietly attached the second one's stock to the
|
||||||
|
// first.
|
||||||
|
func (s *productService) CreateProduct(product models.Products) (models.Products, error) {
|
||||||
|
id, err := s.repo.CreateProductReturningID(product)
|
||||||
|
if err != nil {
|
||||||
|
return models.Products{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
product.Productid = id
|
||||||
|
return product, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *productService) UpdateProduct(product models.Products) error {
|
func (s *productService) UpdateProduct(product models.Products) error {
|
||||||
@@ -308,6 +329,54 @@ func (s *productService) DeleteProductLocation(tenantid, locationid, productid i
|
|||||||
return s.repo.DeleteProductLocation(tenantid, locationid, productid)
|
return s.repo.DeleteProductLocation(tenantid, locationid, productid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// catalogueFactsOf collects the catalogue fields the product table has no
|
||||||
|
// column for, so an import keeps them instead of leaving them behind.
|
||||||
|
//
|
||||||
|
// Only what the catalogue actually stated: an empty field is omitted rather
|
||||||
|
// than written as `""` or `[]`, so a reader can tell "the catalogue did not say"
|
||||||
|
// from "the catalogue said none". The drawer prints a row per fact and an empty
|
||||||
|
// string would print an empty row.
|
||||||
|
//
|
||||||
|
// The keys are the catalogue's own wire names. They are what the console
|
||||||
|
// already reads off a live catalogue row, so the same rendering works against
|
||||||
|
// either source without a translation layer in between.
|
||||||
|
func catalogueFactsOf(p *models.CatalogueProduct) map[string]any {
|
||||||
|
facts := map[string]any{}
|
||||||
|
if p == nil {
|
||||||
|
return facts
|
||||||
|
}
|
||||||
|
|
||||||
|
put := func(key, value string) {
|
||||||
|
if v := strings.TrimSpace(value); v != "" {
|
||||||
|
facts[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
putList := func(key string, values []string) {
|
||||||
|
kept := make([]string, 0, len(values))
|
||||||
|
for _, v := range values {
|
||||||
|
if t := strings.TrimSpace(v); t != "" {
|
||||||
|
kept = append(kept, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) > 0 {
|
||||||
|
facts[key] = kept
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
put("title", p.Title)
|
||||||
|
put("category", p.Category)
|
||||||
|
put("variant_key", p.VariantKey)
|
||||||
|
put("sku_source", p.SKUSource)
|
||||||
|
put("price_range", p.PriceRange)
|
||||||
|
put("fssai_license", p.FSSAILicense)
|
||||||
|
put("search_query", p.SearchQuery)
|
||||||
|
putList("providers", p.Providers)
|
||||||
|
putList("highlights", p.Highlights)
|
||||||
|
putList("nutrients", p.Nutrients)
|
||||||
|
|
||||||
|
return facts
|
||||||
|
}
|
||||||
|
|
||||||
// ImportCatalogueProduct bridges a global catalogue product (CatalogueDB) into
|
// ImportCatalogueProduct bridges a global catalogue product (CatalogueDB) into
|
||||||
// a tenant's own store catalogue: it snapshots the catalogue product into the
|
// a tenant's own store catalogue: it snapshots the catalogue product into the
|
||||||
// tenant's `products` table on first import (keyed on brand+catalogueid so
|
// tenant's `products` table on first import (keyed on brand+catalogueid so
|
||||||
@@ -417,6 +486,27 @@ func (s *productService) ImportCatalogueProduct(reqs []models.ImportCataloguePro
|
|||||||
Taxpercent: req.Taxpercent,
|
Taxpercent: req.Taxpercent,
|
||||||
Approve: 1,
|
Approve: 1,
|
||||||
}
|
}
|
||||||
|
// Everything the snapshot has no column for, kept as the catalogue
|
||||||
|
// stated it.
|
||||||
|
//
|
||||||
|
// Ten of the catalogue's eighteen fields used to stop here. Two of
|
||||||
|
// them SHOULD — `category` is remapped to the platform's own
|
||||||
|
// categoryid, and `price_range` is replaced by the price the shop
|
||||||
|
// sets — but they are kept anyway, because what other retailers
|
||||||
|
// charge is the most useful thing on the drawer when somebody is
|
||||||
|
// deciding what to charge, and the catalogue's own category is how
|
||||||
|
// a mis-filed product gets noticed.
|
||||||
|
//
|
||||||
|
// Encoding failure is swallowed, like the images below: the product
|
||||||
|
// is worth creating without its facts, and refusing an import over
|
||||||
|
// a nutrition line would be the wrong trade.
|
||||||
|
if encoded, err := json.Marshal(catalogueFactsOf(catalogueProduct)); err == nil {
|
||||||
|
snapshot.Cataloguefacts = string(encoded)
|
||||||
|
} else {
|
||||||
|
log.Printf("import: could not encode catalogue facts for %s/%d: %v",
|
||||||
|
req.Brand, req.Catalogueid, err)
|
||||||
|
}
|
||||||
|
|
||||||
if len(catalogueProduct.Images) > 0 {
|
if len(catalogueProduct.Images) > 0 {
|
||||||
// The first stays where every reader already looks for it.
|
// The first stays where every reader already looks for it.
|
||||||
snapshot.Productimage = catalogueProduct.Images[0]
|
snapshot.Productimage = catalogueProduct.Images[0]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
@@ -47,6 +48,10 @@ type fakeProductRepo struct {
|
|||||||
publishedRefs []models.ProductLocationRef
|
publishedRefs []models.ProductLocationRef
|
||||||
created []models.Products
|
created []models.Products
|
||||||
categorySet map[int][2]int // productid -> {categoryid, subcategoryid}
|
categorySet map[int][2]int // productid -> {categoryid, subcategoryid}
|
||||||
|
|
||||||
|
// Set to make the insert fail, for the tests that check a failed create
|
||||||
|
// does not hand back a half-made product.
|
||||||
|
createErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeRepo() *fakeProductRepo {
|
func newFakeRepo() *fakeProductRepo {
|
||||||
@@ -119,6 +124,9 @@ func (f *fakeProductRepo) UpdateProductCategory(productid, categoryid, subcatego
|
|||||||
// re-import branch, so this only became reachable when publishing did.
|
// re-import branch, so this only became reachable when publishing did.
|
||||||
func (f *fakeProductRepo) CreateProductReturningID(product models.Products) (int, error) {
|
func (f *fakeProductRepo) CreateProductReturningID(product models.Products) (int, error) {
|
||||||
f.calls = append(f.calls, "CreateProductReturningID")
|
f.calls = append(f.calls, "CreateProductReturningID")
|
||||||
|
if f.createErr != nil {
|
||||||
|
return 0, f.createErr
|
||||||
|
}
|
||||||
f.created = append(f.created, product)
|
f.created = append(f.created, product)
|
||||||
return 9001, nil
|
return 9001, nil
|
||||||
}
|
}
|
||||||
@@ -767,3 +775,72 @@ func TestPricingFilterDoesNotDisturbTheCallersSlice(t *testing.T) {
|
|||||||
t.Error("the caller's slice was modified")
|
t.Error("the caller's slice was modified")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Creating a product hands back its id ───────────────────────────────────
|
||||||
|
*
|
||||||
|
* `POST /products/create` answered `productid: 0` for every product it created:
|
||||||
|
* the repository took the struct by value, GORM wrote the generated id onto
|
||||||
|
* that copy, and the copy went out of scope. The endpoint is the only way to
|
||||||
|
* create a product, and a product cannot be priced or stocked without its id,
|
||||||
|
* so every caller had to re-read the tenant's whole catalogue and find its own
|
||||||
|
* row again by SKU — a column nothing enforces, in an importer that creates
|
||||||
|
* duplicates by design.
|
||||||
|
*/
|
||||||
|
|
||||||
|
func TestCreateProductReturnsTheIdTheDatabaseAssigned(t *testing.T) {
|
||||||
|
repo := &fakeProductRepo{}
|
||||||
|
svc := NewProductService(repo, &fakeCatalogueService{})
|
||||||
|
|
||||||
|
created, err := svc.CreateProduct(models.Products{
|
||||||
|
Tenantid: 9001,
|
||||||
|
Productname: "Test Rice 5kg",
|
||||||
|
Productsku: "TM-RICE-5K",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProduct: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9001 is what the fake's CreateProductReturningID returns. The point is
|
||||||
|
// that it reaches the caller at all — it used to be dropped.
|
||||||
|
if created.Productid != 9001 {
|
||||||
|
t.Errorf("productid = %d, want 9001 — the id was lost on the way out", created.Productid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rest of the product survives the round trip, because the response is
|
||||||
|
// what the console shows and what it prices and stocks against.
|
||||||
|
if created.Productsku != "TM-RICE-5K" || created.Productname != "Test Rice 5kg" {
|
||||||
|
t.Errorf("the product came back altered: %+v", created)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProductGoesThroughTheOneCreatePath(t *testing.T) {
|
||||||
|
// There were two repository methods doing this same INSERT, one of which
|
||||||
|
// discarded the id. Only one remains, and this is what pins that: a second
|
||||||
|
// path would have to be added here to be used at all.
|
||||||
|
repo := &fakeProductRepo{}
|
||||||
|
svc := NewProductService(repo, &fakeCatalogueService{})
|
||||||
|
|
||||||
|
if _, err := svc.CreateProduct(models.Products{Tenantid: 9001}); err != nil {
|
||||||
|
t.Fatalf("CreateProduct: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(repo.calls) != 1 || repo.calls[0] != "CreateProductReturningID" {
|
||||||
|
t.Errorf("want exactly one call to CreateProductReturningID, got %v", repo.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFailedCreateReturnsNoProduct(t *testing.T) {
|
||||||
|
// The caller prices and stocks against what comes back, so a half-made
|
||||||
|
// product with a zero id would be worse than an error — it would send a
|
||||||
|
// price and a stock movement to product 0.
|
||||||
|
repo := &fakeProductRepo{createErr: errors.New("duplicate key")}
|
||||||
|
svc := NewProductService(repo, &fakeCatalogueService{})
|
||||||
|
|
||||||
|
created, err := svc.CreateProduct(models.Products{Tenantid: 9001, Productsku: "DUP"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a failed insert was reported as a success")
|
||||||
|
}
|
||||||
|
if created.Productid != 0 || created.Productsku != "" {
|
||||||
|
t.Errorf("a product was returned for a failed create: %+v", created)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"nearle/repositories"
|
"nearle/repositories"
|
||||||
"nearle/utils"
|
"nearle/utils"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -47,8 +48,19 @@ const (
|
|||||||
scanLookupTimeout = 5 * time.Second
|
scanLookupTimeout = 5 * time.Second
|
||||||
scanMaxLabelLen = 200
|
scanMaxLabelLen = 200
|
||||||
scanCatalogueTopK = 15
|
scanCatalogueTopK = 15
|
||||||
// Below this the best hit is not shown as a match at all.
|
// Below this the best hit is not shown as a match at all. A correct label
|
||||||
scanMinScore = 0.30
|
// scores ~0.92 against its own product's vector and ~0.23 against an
|
||||||
|
// unrelated one, so the floor sits in the empty middle of that split
|
||||||
|
// rather than just above the unrelated band: at 0.30, "Paracetamol"
|
||||||
|
// came back as "Paneer Makhni 500ml" (0.304) — a near-miss on an
|
||||||
|
// unrelated row clears a floor set that close to the noise.
|
||||||
|
scanMinScore = 0.50
|
||||||
|
// How close the runner-up may be before the leader stops being an answer
|
||||||
|
// and the two become a question. See isAmbiguous.
|
||||||
|
scanAmbiguityMargin = 0.06
|
||||||
|
// A "did you mean?" list longer than this is not a choice, it is a
|
||||||
|
// catalogue — the customer is standing in a shop holding a packet.
|
||||||
|
scanMaxCandidates = 10
|
||||||
)
|
)
|
||||||
|
|
||||||
// ScanErrors the controller maps to statuses. Everything else is a 500.
|
// ScanErrors the controller maps to statuses. Everything else is a 500.
|
||||||
@@ -77,11 +89,15 @@ func NewScanService(repo repositories.ScanRepository, embedder utils.Embedder) S
|
|||||||
|
|
||||||
func (s *scanService) Lookup(ctx context.Context, req models.ScanLookupRequest) (*models.ScanLookupResponse, error) {
|
func (s *scanService) Lookup(ctx context.Context, req models.ScanLookupRequest) (*models.ScanLookupResponse, error) {
|
||||||
label := strings.TrimSpace(req.Label)
|
label := strings.TrimSpace(req.Label)
|
||||||
|
// The caller can name the product outright instead of describing it —
|
||||||
|
// how the app resolves a candidate the customer picked.
|
||||||
|
direct := strings.TrimSpace(req.Brand) != "" && req.Catalogueid > 0
|
||||||
|
|
||||||
if req.Customerid <= 0 {
|
if req.Customerid <= 0 {
|
||||||
return nil, fmt.Errorf("%w: customerid is required", ErrScanBadRequest)
|
return nil, fmt.Errorf("%w: customerid is required", ErrScanBadRequest)
|
||||||
}
|
}
|
||||||
if label == "" {
|
if label == "" && !direct {
|
||||||
return nil, fmt.Errorf("%w: label is required", ErrScanBadRequest)
|
return nil, fmt.Errorf("%w: label, or brand and catalogueid, is required", ErrScanBadRequest)
|
||||||
}
|
}
|
||||||
if len(label) > scanMaxLabelLen {
|
if len(label) > scanMaxLabelLen {
|
||||||
label = label[:scanMaxLabelLen]
|
label = label[:scanMaxLabelLen]
|
||||||
@@ -121,6 +137,10 @@ func (s *scanService) Lookup(ctx context.Context, req models.ScanLookupRequest)
|
|||||||
}()
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
if direct {
|
||||||
|
hits, method, matchErr = s.resolveRef(ctx, req.Brand, req.Catalogueid)
|
||||||
|
return
|
||||||
|
}
|
||||||
hits, method, matchErr = s.searchCatalogue(ctx, label)
|
hits, method, matchErr = s.searchCatalogue(ctx, label)
|
||||||
}()
|
}()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
@@ -141,21 +161,35 @@ func (s *scanService) Lookup(ctx context.Context, req models.ScanLookupRequest)
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp := &models.ScanLookupResponse{
|
resp := &models.ScanLookupResponse{
|
||||||
Label: label,
|
Label: label,
|
||||||
Stores: []models.ScanStoreOffer{},
|
Stores: []models.ScanStoreOffer{},
|
||||||
Variants: []models.ScanCatalogueMatch{},
|
Variants: []models.ScanCatalogueMatch{},
|
||||||
|
Candidates: []models.ScanCatalogueMatch{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the app's idea of the customer's tenants against the truth.
|
// Verify the app's idea of the customer's tenants against the truth.
|
||||||
stores, resp.UnregisteredTenantids = restrictToTenants(stores, req.Tenantids)
|
stores, resp.UnregisteredTenantids = restrictToTenants(stores, req.Tenantids)
|
||||||
|
|
||||||
if len(hits) == 0 || hits[0].score < scanMinScore {
|
switch {
|
||||||
|
case len(hits) == 0 && direct:
|
||||||
|
resp.Message = "That product is no longer in the catalogue."
|
||||||
|
return resp, nil
|
||||||
|
case len(hits) == 0, hits[0].score < scanMinScore:
|
||||||
resp.Message = "We couldn't recognise that product. Try a clearer photo of the front of the pack."
|
resp.Message = "We couldn't recognise that product. Try a clearer photo of the front of the pack."
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
best := hits[0]
|
distinct := distinctProducts(hits)
|
||||||
family := catalogueFamily(hits)
|
|
||||||
|
// Several products fit and none of them clearly wins — a bare brand name
|
||||||
|
// or a generic word. Ask rather than guess: naming one of them would put
|
||||||
|
// a confident price on a product the customer did not photograph.
|
||||||
|
if !direct && isAmbiguous(distinct) {
|
||||||
|
return s.candidatesResponse(ctx, resp, hits, distinct, method, stores)
|
||||||
|
}
|
||||||
|
|
||||||
|
best := distinct[0]
|
||||||
|
family := catalogueFamily(hits, best)
|
||||||
resp.Match = ptr(best.toMatch(method))
|
resp.Match = ptr(best.toMatch(method))
|
||||||
resp.Confidence = round3(best.score)
|
resp.Confidence = round3(best.score)
|
||||||
for _, h := range family {
|
for _, h := range family {
|
||||||
@@ -174,11 +208,7 @@ func (s *scanService) Lookup(ctx context.Context, req models.ScanLookupRequest)
|
|||||||
keys = append(keys, repositories.CatalogueKey{Brand: h.Brand, Catalogueid: h.ID, Imageid: h.ImageID})
|
keys = append(keys, repositories.CatalogueKey{Brand: h.Brand, Catalogueid: h.ID, Imageid: h.ImageID})
|
||||||
names = append(names, h.ProductName)
|
names = append(names, h.ProductName)
|
||||||
}
|
}
|
||||||
locationids := make([]int, 0, len(stores))
|
rows, err := s.repo.StoreOptions(ctx, locationIDs(stores), keys, names)
|
||||||
for _, st := range stores {
|
|
||||||
locationids = append(locationids, st.Locationid)
|
|
||||||
}
|
|
||||||
rows, err := s.repo.StoreOptions(ctx, locationids, keys, names)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -208,6 +238,137 @@ func (s *scanService) Lookup(ctx context.Context, req models.ScanLookupRequest)
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// candidatesResponse answers an ambiguous label with the products to choose
|
||||||
|
// between, marking which of them the customer can actually buy right now.
|
||||||
|
//
|
||||||
|
// The availability read is the same StoreOptions query the confident path
|
||||||
|
// runs, widened to every candidate — so a "did you mean?" list can put the
|
||||||
|
// three that are in stock above the five that are not, instead of sending
|
||||||
|
// somebody to a shelf that has none of them.
|
||||||
|
func (s *scanService) candidatesResponse(ctx context.Context, resp *models.ScanLookupResponse,
|
||||||
|
hits, distinct []scoredHit, method string, stores []models.ScanStore) (*models.ScanLookupResponse, error) {
|
||||||
|
|
||||||
|
candidates := distinct
|
||||||
|
if len(candidates) > scanMaxCandidates {
|
||||||
|
candidates = candidates[:scanMaxCandidates]
|
||||||
|
}
|
||||||
|
resp.Ambiguous = true
|
||||||
|
resp.Confidence = round3(distinct[0].score)
|
||||||
|
|
||||||
|
// Every catalogue row belonging to a candidate, and a way back from what
|
||||||
|
// a tenant's product row carries to the candidate it stands for.
|
||||||
|
inCandidates := make(map[string]bool, len(candidates))
|
||||||
|
for _, c := range candidates {
|
||||||
|
inCandidates[c.productKey()] = true
|
||||||
|
}
|
||||||
|
var keys []repositories.CatalogueKey
|
||||||
|
var names []string
|
||||||
|
byImage := make(map[string]string)
|
||||||
|
byRef := make(map[string]string)
|
||||||
|
byName := make(map[string]string)
|
||||||
|
for _, h := range hits {
|
||||||
|
key := h.productKey()
|
||||||
|
if !inCandidates[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, repositories.CatalogueKey{Brand: h.Brand, Catalogueid: h.ID, Imageid: h.ImageID})
|
||||||
|
names = append(names, h.ProductName)
|
||||||
|
if h.ImageID != "" {
|
||||||
|
byImage[h.ImageID] = key
|
||||||
|
}
|
||||||
|
byRef[refKey(h.Brand, h.ID)] = key
|
||||||
|
if n := strings.ToLower(strings.TrimSpace(h.ProductName)); n != "" {
|
||||||
|
byName[n] = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stocked := make(map[string]bool)
|
||||||
|
if len(stores) > 0 && len(keys) > 0 {
|
||||||
|
rows, err := s.repo.StoreOptions(ctx, locationIDs(stores), keys, names)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Stock <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Same precedence as optionFromRow: the stable key first.
|
||||||
|
if row.Imageid != "" {
|
||||||
|
if key, ok := byImage[row.Imageid]; ok {
|
||||||
|
stocked[key] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row.Catalogueid > 0 {
|
||||||
|
if key, ok := byRef[refKey(row.Productbrand, row.Catalogueid)]; ok {
|
||||||
|
stocked[key] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if key, ok := byName[strings.ToLower(strings.TrimSpace(row.Productname))]; ok {
|
||||||
|
stocked[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range candidates {
|
||||||
|
m := c.toMatch(method)
|
||||||
|
m.Available = stocked[c.productKey()]
|
||||||
|
resp.Candidates = append(resp.Candidates, m)
|
||||||
|
}
|
||||||
|
// Buyable first; within each group the search's own ranking stands.
|
||||||
|
sort.SliceStable(resp.Candidates, func(i, j int) bool {
|
||||||
|
return resp.Candidates[i].Available && !resp.Candidates[j].Available
|
||||||
|
})
|
||||||
|
|
||||||
|
available := 0
|
||||||
|
for _, c := range resp.Candidates {
|
||||||
|
if c.Available {
|
||||||
|
available++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if available > 0 {
|
||||||
|
resp.Message = fmt.Sprintf("Which one is it? %d of these %d are in stock near you.",
|
||||||
|
available, len(resp.Candidates))
|
||||||
|
} else {
|
||||||
|
resp.Message = fmt.Sprintf("Which one is it? We found %d products that could match.",
|
||||||
|
len(resp.Candidates))
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRef reads the product the caller named, with its pack sizes. No
|
||||||
|
// recognition, so every row scores 1 and the method says so.
|
||||||
|
func (s *scanService) resolveRef(ctx context.Context, brand string, id int64) ([]scoredHit, string, error) {
|
||||||
|
rows, err := s.repo.CatalogueRef(ctx, brand, id)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, repositories.ErrCatalogueDBUnavailable):
|
||||||
|
return nil, "direct", ErrScanCatalogueDown
|
||||||
|
case errors.Is(err, repositories.ErrUnknownBrand):
|
||||||
|
return nil, "direct", fmt.Errorf("%w: unknown brand %q", ErrScanBadRequest, brand)
|
||||||
|
}
|
||||||
|
return nil, "direct", err
|
||||||
|
}
|
||||||
|
hits := make([]scoredHit, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
hits = append(hits, scoredHit{CatalogueHit: row, score: 1})
|
||||||
|
}
|
||||||
|
return hits, "direct", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func refKey(brand string, id int64) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(brand)) + "#" + strconv.FormatInt(id, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationIDs(stores []models.ScanStore) []int {
|
||||||
|
ids := make([]int, 0, len(stores))
|
||||||
|
for _, st := range stores {
|
||||||
|
ids = append(ids, st.Locationid)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
// ── Confirm ─────────────────────────────────────────────────────────────────
|
// ── Confirm ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (s *scanService) Confirm(ctx context.Context, req models.ScanConfirmRequest) (*models.ScanConfirmResponse, error) {
|
func (s *scanService) Confirm(ctx context.Context, req models.ScanConfirmRequest) (*models.ScanConfirmResponse, error) {
|
||||||
@@ -241,6 +402,14 @@ func (s *scanService) Confirm(ctx context.Context, req models.ScanConfirmRequest
|
|||||||
}
|
}
|
||||||
resp.Store = chosen
|
resp.Store = chosen
|
||||||
|
|
||||||
|
// Distance on the store they tapped, for every outcome and not just the
|
||||||
|
// out-of-stock one below: the app renders this store from the reply it
|
||||||
|
// gets. The phone's fix is free to parse; the saved address costs a
|
||||||
|
// query, so it is only reached for on the path that also ranks other
|
||||||
|
// outlets. Without either, distanceKm leaves the -1 the repository set.
|
||||||
|
lat, lng, hasPos := utils.ParseLatLng(string(req.Latitude), string(req.Longitude))
|
||||||
|
chosen.DistanceKm = distanceKm(*chosen, lat, lng, hasPos)
|
||||||
|
|
||||||
row, err := s.repo.ProductAt(ctx, req.Tenantid, req.Locationid, req.Productid)
|
row, err := s.repo.ProductAt(ctx, req.Tenantid, req.Locationid, req.Productid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -268,10 +437,10 @@ func (s *scanService) Confirm(ctx context.Context, req models.ScanConfirmRequest
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The same product elsewhere, nearest first, with enough of it.
|
// The same product elsewhere, nearest first, with enough of it.
|
||||||
lat, lng, hasPos := utils.ParseLatLng(string(req.Latitude), string(req.Longitude))
|
|
||||||
if !hasPos {
|
if !hasPos {
|
||||||
if hl, hg, ok, err := s.repo.CustomerHome(ctx, req.Customerid); err == nil && ok {
|
if hl, hg, ok, err := s.repo.CustomerHome(ctx, req.Customerid); err == nil && ok {
|
||||||
lat, lng, hasPos = hl, hg, true
|
lat, lng, hasPos = hl, hg, true
|
||||||
|
chosen.DistanceKm = distanceKm(*chosen, lat, lng, hasPos)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
others := make([]models.ScanStore, 0, len(stores))
|
others := make([]models.ScanStore, 0, len(stores))
|
||||||
@@ -467,15 +636,33 @@ func scoreCachedHits(cached []repositories.CatalogueHit) []scoredHit {
|
|||||||
return hits
|
return hits
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sortHits ranks by blended score, then by the model's own similarity, and
|
||||||
|
// only then by name. Name alone used to break every tie, which quietly made
|
||||||
|
// punctuation decide relevance: "Parle Monaco Classic" sorts above "Parle-G
|
||||||
|
// Original …" because a space precedes a hyphen in ASCII, so equal-scoring
|
||||||
|
// crackers beat the biscuit that was actually scanned.
|
||||||
func sortHits(hits []scoredHit) {
|
func sortHits(hits []scoredHit) {
|
||||||
sort.SliceStable(hits, func(i, j int) bool {
|
sort.SliceStable(hits, func(i, j int) bool {
|
||||||
if hits[i].score != hits[j].score {
|
if hits[i].score != hits[j].score {
|
||||||
return hits[i].score > hits[j].score
|
return hits[i].score > hits[j].score
|
||||||
}
|
}
|
||||||
|
di, dj := vectorRank(hits[i].Distance), vectorRank(hits[j].Distance)
|
||||||
|
if di != dj {
|
||||||
|
return di < dj
|
||||||
|
}
|
||||||
return hits[i].ProductName < hits[j].ProductName
|
return hits[i].ProductName < hits[j].ProductName
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vectorRank orders by cosine distance, nearest first, with a row the model
|
||||||
|
// never saw (-1, text-only) sorting behind every row it did.
|
||||||
|
func vectorRank(d float64) float64 {
|
||||||
|
if d < 0 {
|
||||||
|
return math.MaxFloat64
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
func (s *scanService) modelName() string {
|
func (s *scanService) modelName() string {
|
||||||
if s.embedder == nil {
|
if s.embedder == nil {
|
||||||
return "none"
|
return "none"
|
||||||
@@ -499,12 +686,29 @@ func (s *scanService) embed(ctx context.Context, label string) ([]float32, error
|
|||||||
// The whole label as a substring of the name is near-certain; otherwise the
|
// The whole label as a substring of the name is near-certain; otherwise the
|
||||||
// share of label words found in name+title, scaled so that "all of them"
|
// share of label words found in name+title, scaled so that "all of them"
|
||||||
// stops short of the substring case.
|
// stops short of the substring case.
|
||||||
|
//
|
||||||
|
// A label that is a substring of MANY names — a bare brand, "britannia" —
|
||||||
|
// therefore scores them all 0.95, identically. That tie is not a flaw to
|
||||||
|
// score around: it is the signal, and isAmbiguous reads it to answer "did
|
||||||
|
// you mean?" rather than letting the sort order pick a winner.
|
||||||
func textScore(h repositories.CatalogueHit, label string, tokens []string) float64 {
|
func textScore(h repositories.CatalogueHit, label string, tokens []string) float64 {
|
||||||
name := strings.ToLower(h.ProductName)
|
name := strings.ToLower(h.ProductName)
|
||||||
hay := name + " " + strings.ToLower(h.Title)
|
hay := name + " " + strings.ToLower(h.Title)
|
||||||
label = strings.ToLower(strings.TrimSpace(label))
|
label = strings.ToLower(strings.TrimSpace(label))
|
||||||
if label != "" && strings.Contains(name, label) {
|
// The substring test compares separator-folded forms, so the brand's own
|
||||||
return 0.95
|
// punctuation does not decide the match: "Parle G", "Parle-G" and
|
||||||
|
// "ParleG" all have to reach "Parle-G Original Glucose Biscuits".
|
||||||
|
if label != "" {
|
||||||
|
foldedName, foldedLabel := utils.FoldSeparators(name), utils.FoldSeparators(label)
|
||||||
|
if foldedLabel != "" && strings.Contains(foldedName, foldedLabel) {
|
||||||
|
return 0.95
|
||||||
|
}
|
||||||
|
// Separators dropped rather than folded. Only for a label long enough
|
||||||
|
// that a run of letters means something — "lay" inside "malayalam" is
|
||||||
|
// not a match anyone wants.
|
||||||
|
if tight := utils.TightenLabel(label); len(tight) >= 4 && strings.Contains(utils.TightenLabel(name), tight) {
|
||||||
|
return 0.95
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(tokens) == 0 {
|
if len(tokens) == 0 {
|
||||||
return 0
|
return 0
|
||||||
@@ -518,28 +722,65 @@ func textScore(h repositories.CatalogueHit, label string, tokens []string) float
|
|||||||
return 0.8 * float64(found) / float64(len(tokens))
|
return 0.8 * float64(found) / float64(len(tokens))
|
||||||
}
|
}
|
||||||
|
|
||||||
// catalogueFamily is the best hit and its other pack sizes: same brand, and
|
// productKey identifies a product across its pack sizes: the catalogue's own
|
||||||
// the same variant_key when the catalogue assigned one, else the same name.
|
// variant_key where it assigned one, the name otherwise, always within a
|
||||||
// Every member is a separate catalogue row a shop may have imported.
|
// brand. Two rows sharing it are 100 g and 200 g of one thing; two rows that
|
||||||
func catalogueFamily(hits []scoredHit) []scoredHit {
|
// do not are different products to choose between.
|
||||||
if len(hits) == 0 {
|
func productKey(h repositories.CatalogueHit) string {
|
||||||
return nil
|
if k := strings.TrimSpace(h.VariantKey); k != "" {
|
||||||
|
return h.Brand + "/" + strings.ToLower(k)
|
||||||
}
|
}
|
||||||
best := hits[0]
|
return h.Brand + "/" + strings.ToLower(strings.TrimSpace(h.ProductName))
|
||||||
family := []scoredHit{best}
|
}
|
||||||
for _, h := range hits[1:] {
|
|
||||||
if h.Brand != best.Brand {
|
// productKey of a scored hit — scoredHit embeds the row it scored.
|
||||||
|
func (h scoredHit) productKey() string { return productKey(h.CatalogueHit) }
|
||||||
|
|
||||||
|
// distinctProducts keeps the best-scoring row of each product, in rank
|
||||||
|
// order — the list of things the customer could actually be shown to choose
|
||||||
|
// between, as opposed to the same product listed four times in four sizes.
|
||||||
|
func distinctProducts(hits []scoredHit) []scoredHit {
|
||||||
|
seen := make(map[string]bool, len(hits))
|
||||||
|
out := make([]scoredHit, 0, len(hits))
|
||||||
|
for _, h := range hits {
|
||||||
|
key := h.productKey()
|
||||||
|
if seen[key] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch {
|
seen[key] = true
|
||||||
case best.VariantKey != "" && h.VariantKey != "":
|
out = append(out, h)
|
||||||
if h.VariantKey == best.VariantKey {
|
}
|
||||||
family = append(family, h)
|
return out
|
||||||
}
|
}
|
||||||
case strings.EqualFold(strings.TrimSpace(h.ProductName), strings.TrimSpace(best.ProductName)):
|
|
||||||
|
// isAmbiguous reports that naming the leader as THE match would be a guess
|
||||||
|
// dressed up as an answer, because something else is level with it.
|
||||||
|
//
|
||||||
|
// A margin rather than an absolute threshold: what matters is not how high
|
||||||
|
// the best score is but whether anything is tied with it. A bare brand name
|
||||||
|
// is a substring of every one of that brand's names, so textScore gives them
|
||||||
|
// all 0.95 — a perfect tie at a HIGH score, which no floor would catch.
|
||||||
|
//
|
||||||
|
// Erring towards asking is deliberate. Asking costs the customer one tap on
|
||||||
|
// a picture; guessing wrong costs them the wrong biscuit and costs us the
|
||||||
|
// belief that the scanner works. A label that names one product leaves the
|
||||||
|
// runner-up far behind, so the common case is unaffected.
|
||||||
|
func isAmbiguous(distinct []scoredHit) bool {
|
||||||
|
return len(distinct) >= 2 && distinct[1].score >= distinct[0].score-scanAmbiguityMargin
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalogueFamily is `of` and its other pack sizes, drawn from hits.
|
||||||
|
func catalogueFamily(hits []scoredHit, of scoredHit) []scoredHit {
|
||||||
|
key := of.productKey()
|
||||||
|
family := make([]scoredHit, 0, 4)
|
||||||
|
for _, h := range hits {
|
||||||
|
if h.productKey() == key {
|
||||||
family = append(family, h)
|
family = append(family, h)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(family) == 0 {
|
||||||
|
return []scoredHit{of}
|
||||||
|
}
|
||||||
return family
|
return family
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ package services
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
"nearle/repositories"
|
"nearle/repositories"
|
||||||
|
"nearle/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -30,9 +33,13 @@ type fakeScanRepo struct {
|
|||||||
options []repositories.StoreOptionRow
|
options []repositories.StoreOptionRow
|
||||||
at map[int]*repositories.StoreOptionRow // productid → row
|
at map[int]*repositories.StoreOptionRow // productid → row
|
||||||
|
|
||||||
|
ref []repositories.CatalogueHit
|
||||||
|
refErr error
|
||||||
|
|
||||||
askedKeys []repositories.CatalogueKey
|
askedKeys []repositories.CatalogueKey
|
||||||
askedNames []string
|
askedNames []string
|
||||||
askedLocs []int
|
askedLocs []int
|
||||||
|
askedRef string
|
||||||
cachedHits map[string][]repositories.CatalogueHit
|
cachedHits map[string][]repositories.CatalogueHit
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +76,10 @@ func (f *fakeScanRepo) TextSearch(context.Context, string, int) ([]repositories.
|
|||||||
return f.text, nil
|
return f.text, nil
|
||||||
}
|
}
|
||||||
func (f *fakeScanRepo) VectorSearchAvailable() bool { return f.hasVec }
|
func (f *fakeScanRepo) VectorSearchAvailable() bool { return f.hasVec }
|
||||||
|
func (f *fakeScanRepo) CatalogueRef(_ context.Context, brand string, id int64) ([]repositories.CatalogueHit, error) {
|
||||||
|
f.askedRef = fmt.Sprintf("%s#%d", brand, id)
|
||||||
|
return f.ref, f.refErr
|
||||||
|
}
|
||||||
func (f *fakeScanRepo) CachedVector(context.Context, string, string) ([]float32, bool) {
|
func (f *fakeScanRepo) CachedVector(context.Context, string, string) ([]float32, bool) {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
@@ -423,7 +434,7 @@ func TestCatalogueFamilyGroupsByVariantKeyThenName(t *testing.T) {
|
|||||||
{CatalogueHit: milkBikis200, score: 0.88},
|
{CatalogueHit: milkBikis200, score: 0.88},
|
||||||
{CatalogueHit: repositories.CatalogueHit{Brand: "parle", ProductName: "Milk Bikis", VariantKey: "milk_bikis"}, score: 0.5},
|
{CatalogueHit: repositories.CatalogueHit{Brand: "parle", ProductName: "Milk Bikis", VariantKey: "milk_bikis"}, score: 0.5},
|
||||||
}
|
}
|
||||||
family := catalogueFamily(hits)
|
family := catalogueFamily(hits, hits[0])
|
||||||
if len(family) != 2 || family[1].ID != 8 {
|
if len(family) != 2 || family[1].ID != 8 {
|
||||||
t.Fatalf("family should be the two britannia sizes, got %+v", family)
|
t.Fatalf("family should be the two britannia sizes, got %+v", family)
|
||||||
}
|
}
|
||||||
@@ -432,7 +443,342 @@ func TestCatalogueFamilyGroupsByVariantKeyThenName(t *testing.T) {
|
|||||||
a := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 1, ProductName: "Honey"}}
|
a := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 1, ProductName: "Honey"}}
|
||||||
b := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 2, ProductName: "honey "}}
|
b := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 2, ProductName: "honey "}}
|
||||||
c := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 3, ProductName: "Honey Lite"}}
|
c := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 3, ProductName: "Honey Lite"}}
|
||||||
if family := catalogueFamily([]scoredHit{a, b, c}); len(family) != 2 {
|
if family := catalogueFamily([]scoredHit{a, b, c}, a); len(family) != 2 {
|
||||||
t.Errorf("name match should join 1 and 2 only, got %+v", family)
|
t.Errorf("name match should join 1 and 2 only, got %+v", family)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Ambiguity.
|
||||||
|
|
||||||
|
Lens hands back whatever was most legible on the packet, and on a packet that
|
||||||
|
is very often the brand wordmark alone. "britannia" fits 258 catalogue rows
|
||||||
|
equally well, so there is no best one. textScore gives all of them 0.95 —
|
||||||
|
correctly, the label IS in every one of those names — and with nothing to
|
||||||
|
read that tie, the sort order picked a winner and the customer was shown one
|
||||||
|
arbitrary biscuit with "confidence": 0.95 and a price. These tests are the
|
||||||
|
contract that it asks instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Three different Britannia products, of which the customer's stores stock
|
||||||
|
// one. Text-only: no embedder, which is also how production runs until the
|
||||||
|
// model is configured.
|
||||||
|
func newBrandLabelFixture() *fakeScanRepo {
|
||||||
|
cashew := repositories.CatalogueHit{Brand: "britannia", ID: 21, ProductName: "Britannia Good Day Cashew Cookies", VariantKey: "good_day_cashew", ImageID: "britannia_good_day_cashew"}
|
||||||
|
butter := repositories.CatalogueHit{Brand: "britannia", ID: 22, ProductName: "Britannia Good Day Butter Cookies", VariantKey: "good_day_butter", ImageID: "britannia_good_day_butter"}
|
||||||
|
marie := repositories.CatalogueHit{Brand: "britannia", ID: 23, ProductName: "Britannia Marie Gold", VariantKey: "marie_gold", ImageID: "britannia_marie_gold"}
|
||||||
|
|
||||||
|
return &fakeScanRepo{
|
||||||
|
exists: true,
|
||||||
|
stores: fixtureStores(),
|
||||||
|
text: []repositories.CatalogueHit{cashew, butter, marie},
|
||||||
|
options: []repositories.StoreOptionRow{
|
||||||
|
{Tenantid: 2, Locationid: 20, Productid: 220, Productname: "Britannia Marie Gold",
|
||||||
|
Productbrand: "britannia", Catalogueid: 23, Imageid: "britannia_marie_gold", Price: 30, Stock: 4},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABareBrandNameAsksInsteadOfGuessing(t *testing.T) {
|
||||||
|
repo := newBrandLabelFixture()
|
||||||
|
svc := NewScanService(repo, nil)
|
||||||
|
|
||||||
|
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{
|
||||||
|
Customerid: 5, Label: "britannia", Latitude: "11.035", Longitude: "77.035",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.Ambiguous {
|
||||||
|
t.Fatalf("a bare brand name must not resolve to one product, got match %+v", resp.Match)
|
||||||
|
}
|
||||||
|
if resp.Match != nil {
|
||||||
|
t.Errorf("Match must be nil while ambiguous, got %+v", resp.Match)
|
||||||
|
}
|
||||||
|
if len(resp.Stores) != 0 {
|
||||||
|
t.Errorf("no store or price may be quoted for a product the customer has not chosen, got %d offers", len(resp.Stores))
|
||||||
|
}
|
||||||
|
if len(resp.Variants) != 0 {
|
||||||
|
t.Errorf("pack sizes belong to a chosen product, got %+v", resp.Variants)
|
||||||
|
}
|
||||||
|
if len(resp.Candidates) != 3 {
|
||||||
|
t.Fatalf("want the three distinct Britannia products, got %d: %+v", len(resp.Candidates), resp.Candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one the customer can actually buy is offered first.
|
||||||
|
if !resp.Candidates[0].Available || resp.Candidates[0].Catalogueid != 23 {
|
||||||
|
t.Errorf("the stocked product should lead the list, got %+v", resp.Candidates[0])
|
||||||
|
}
|
||||||
|
for _, c := range resp.Candidates[1:] {
|
||||||
|
if c.Available {
|
||||||
|
t.Errorf("only Marie Gold is stocked, but %s reports available", c.ProductName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Note what confidence does NOT say here. The label appears verbatim in
|
||||||
|
// all three names, so relevance is high — and the answer is still a
|
||||||
|
// question. An app that gated on `confidence` instead of `ambiguous`
|
||||||
|
// would show a price for the wrong biscuit, which is the whole bug.
|
||||||
|
if resp.Confidence < 0.9 {
|
||||||
|
t.Errorf("a verbatim brand match scores high; %v suggests the scoring changed", resp.Confidence)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Message, "Which one") {
|
||||||
|
t.Errorf("the message should ask, got %q", resp.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAmbiguousLabelWithNoStockStillLists(t *testing.T) {
|
||||||
|
repo := newBrandLabelFixture()
|
||||||
|
repo.options = nil
|
||||||
|
svc := NewScanService(repo, nil)
|
||||||
|
|
||||||
|
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "britannia"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !resp.Ambiguous || len(resp.Candidates) != 3 {
|
||||||
|
t.Fatalf("want three candidates, got ambiguous=%v %d", resp.Ambiguous, len(resp.Candidates))
|
||||||
|
}
|
||||||
|
for _, c := range resp.Candidates {
|
||||||
|
if c.Available {
|
||||||
|
t.Errorf("%s cannot be available with no stock anywhere", c.ProductName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other half of the contract: a label that does name a product must not
|
||||||
|
// start asking questions.
|
||||||
|
func TestASpecificLabelStillWinsOutright(t *testing.T) {
|
||||||
|
repo := newBrandLabelFixture()
|
||||||
|
svc := NewScanService(repo, nil)
|
||||||
|
|
||||||
|
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{
|
||||||
|
Customerid: 5, Label: "good day cashew",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Ambiguous {
|
||||||
|
t.Fatalf("a label naming one product should resolve, got candidates %+v", resp.Candidates)
|
||||||
|
}
|
||||||
|
if resp.Match == nil || resp.Match.Catalogueid != 21 {
|
||||||
|
t.Fatalf("want the cashew cookies, got %+v", resp.Match)
|
||||||
|
}
|
||||||
|
if len(resp.Candidates) != 0 {
|
||||||
|
t.Errorf("candidates belong to an ambiguous answer, got %+v", resp.Candidates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The property the ambiguity check rests on: a label that is a substring of
|
||||||
|
// several names scores them EQUALLY. Nothing downstream can tell "did you
|
||||||
|
// mean?" from "found it" if a formula breaks that tie on name length, word
|
||||||
|
// count or anything else incidental — which is how one arbitrary Britannia
|
||||||
|
// biscuit used to come back with a price on it.
|
||||||
|
func TestABrandNameScoresItsProductsIdentically(t *testing.T) {
|
||||||
|
cashew := repositories.CatalogueHit{ProductName: "Britannia Good Day Cashew Cookies 200g"}
|
||||||
|
butter := repositories.CatalogueHit{ProductName: "Britannia Good Day Butter Cookies 100g"}
|
||||||
|
// Deliberately a much shorter name: length must not become a tie-breaker.
|
||||||
|
marie := repositories.CatalogueHit{ProductName: "Britannia Marie Gold"}
|
||||||
|
|
||||||
|
tokens := utils.SearchTokens("britannia")
|
||||||
|
a, b, c := textScore(cashew, "britannia", tokens), textScore(butter, "britannia", tokens), textScore(marie, "britannia", tokens)
|
||||||
|
if a != b || b != c {
|
||||||
|
t.Fatalf("a brand must score its products equally, got %.3f / %.3f / %.3f", a, b, c)
|
||||||
|
}
|
||||||
|
if a == 0 {
|
||||||
|
t.Fatal("the brand name is in every one of those names; scoring it 0 would hide them all")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a label that does name a product must NOT tie with its siblings,
|
||||||
|
// or everything would be a question.
|
||||||
|
specific := utils.SearchTokens("good day cashew")
|
||||||
|
if textScore(cashew, "good day cashew", specific) <= textScore(butter, "good day cashew", specific) {
|
||||||
|
t.Error("a label naming one product must outscore its siblings")
|
||||||
|
}
|
||||||
|
|
||||||
|
if none := textScore(cashew, "dabur honey", utils.SearchTokens("dabur honey")); none != 0 {
|
||||||
|
t.Errorf("nothing in common should score 0, got %.3f", none)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDistinctProductsCollapsesPackSizes(t *testing.T) {
|
||||||
|
hits := []scoredHit{
|
||||||
|
{CatalogueHit: milkBikis, score: 1},
|
||||||
|
{CatalogueHit: milkBikis200, score: 0.9},
|
||||||
|
{CatalogueHit: goodDay, score: 0.5},
|
||||||
|
}
|
||||||
|
distinct := distinctProducts(hits)
|
||||||
|
if len(distinct) != 2 || distinct[0].ID != 7 || distinct[1].ID != 9 {
|
||||||
|
t.Fatalf("two sizes of one product are one choice, got %+v", distinct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picking a candidate: the app sends the key instead of a description, and
|
||||||
|
// nothing is recognised at all.
|
||||||
|
func TestNamingTheProductSkipsRecognition(t *testing.T) {
|
||||||
|
repo := newLookupFixture()
|
||||||
|
repo.ref = []repositories.CatalogueHit{milkBikis, milkBikis200}
|
||||||
|
// If recognition ran, these would decide the answer instead.
|
||||||
|
repo.text = []repositories.CatalogueHit{goodDay}
|
||||||
|
repo.vector = []repositories.CatalogueHit{goodDay}
|
||||||
|
svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}})
|
||||||
|
|
||||||
|
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{
|
||||||
|
Customerid: 5, Brand: "britannia", Catalogueid: 7,
|
||||||
|
Latitude: "11.035", Longitude: "77.035",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if repo.askedRef != "britannia#7" {
|
||||||
|
t.Fatalf("the catalogue should have been asked for that exact product, got %q", repo.askedRef)
|
||||||
|
}
|
||||||
|
if resp.Match == nil || resp.Match.Catalogueid != 7 || resp.Match.Method != "direct" {
|
||||||
|
t.Fatalf("want a direct match on 7, got %+v", resp.Match)
|
||||||
|
}
|
||||||
|
if resp.Ambiguous || resp.Confidence != 1 {
|
||||||
|
t.Errorf("a named product is not a guess: ambiguous=%v confidence=%v", resp.Ambiguous, resp.Confidence)
|
||||||
|
}
|
||||||
|
if len(resp.Variants) != 2 {
|
||||||
|
t.Errorf("its pack sizes should come with it, got %+v", resp.Variants)
|
||||||
|
}
|
||||||
|
if !resp.Available || resp.RecommendedLocationid != 20 {
|
||||||
|
t.Errorf("stores are resolved exactly as for a recognised product, got %+v", resp.Stores)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMissingCatalogueRefIsNotAMatch(t *testing.T) {
|
||||||
|
repo := newLookupFixture()
|
||||||
|
repo.ref = nil
|
||||||
|
svc := NewScanService(repo, nil)
|
||||||
|
|
||||||
|
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{
|
||||||
|
Customerid: 5, Brand: "britannia", Catalogueid: 999,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Match != nil || resp.Ambiguous || len(resp.Stores) != 0 {
|
||||||
|
t.Fatalf("a product that is gone is not a match, got %+v", resp)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Message, "no longer") {
|
||||||
|
t.Errorf("the message should say the product is gone, got %q", resp.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A vector neighbour that is merely not-quite-unrelated used to clear the old
|
||||||
|
// 0.30 floor: in production "Paracetamol" came back as "Paneer Makhni 500ml"
|
||||||
|
// on a 0.304 similarity. Correct labels land near 0.92, so nothing this weak
|
||||||
|
// is a match.
|
||||||
|
func TestLookupRefusesANearMissAboveTheOldFloor(t *testing.T) {
|
||||||
|
repo := newLookupFixture()
|
||||||
|
repo.vector = []repositories.CatalogueHit{{Brand: "amul", ID: 4, ProductName: "Paneer Makhni 500ml", Distance: 0.696}} // score 0.304
|
||||||
|
repo.text = nil
|
||||||
|
svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}})
|
||||||
|
|
||||||
|
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "Paracetamol"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Match != nil {
|
||||||
|
t.Fatalf("0.304 is a near-miss, not a match; got %+v", resp.Match)
|
||||||
|
}
|
||||||
|
if resp.Available || len(resp.Stores) != 0 {
|
||||||
|
t.Fatalf("nothing should be offered without a match; got %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm answers about the store the customer tapped, so that store carries a
|
||||||
|
// distance on every outcome — not only on the out-of-stock path that ranks
|
||||||
|
// alternatives. Absent any position it stays -1, the documented "unknown".
|
||||||
|
func TestConfirmReportsDistanceToTheChosenStore(t *testing.T) {
|
||||||
|
repo := newLookupFixture()
|
||||||
|
repo.at = map[int]*repositories.StoreOptionRow{200: &repo.options[1]}
|
||||||
|
svc := NewScanService(repo, nil)
|
||||||
|
req := models.ScanConfirmRequest{Customerid: 5, Tenantid: 2, Locationid: 20, Productid: 200, Quantity: 4}
|
||||||
|
|
||||||
|
withPos := req
|
||||||
|
withPos.Latitude, withPos.Longitude = "11.035", "77.035"
|
||||||
|
resp, err := svc.Confirm(context.Background(), withPos)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !resp.Ok || resp.Store == nil {
|
||||||
|
t.Fatalf("expected the in-stock answer, got %+v", resp)
|
||||||
|
}
|
||||||
|
if resp.Store.DistanceKm <= 0 {
|
||||||
|
t.Fatalf("the phone sent a fix, so the tapped store has a distance; got %v", resp.Store.DistanceKm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No fix from the phone, but a saved address on file.
|
||||||
|
repo.homeLat, repo.homeLng, repo.homeOK = 11.035, 77.035, true
|
||||||
|
resp, err = svc.Confirm(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Store == nil || resp.Store.DistanceKm != -1 {
|
||||||
|
t.Fatalf("in stock is answered without reaching for the saved address; got %v", resp.Store)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither: unknown, and the app sorts it last.
|
||||||
|
repo.homeOK = false
|
||||||
|
resp, err = svc.Confirm(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Store == nil || resp.Store.DistanceKm != -1 {
|
||||||
|
t.Fatalf("no position at all is -1; got %v", resp.Store)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var parleG = repositories.CatalogueHit{Brand: "parle", ID: 1, ProductName: "Parle-G Original Glucose Biscuits 250g", Title: "Parle-G", VariantKey: "parle_g", ImageID: "parle_parle_g_250g", Distance: 0.20}
|
||||||
|
var monaco = repositories.CatalogueHit{Brand: "parle", ID: 2, ProductName: "Parle Monaco Classic Regular 200g", Title: "Monaco", VariantKey: "monaco", ImageID: "parle_monaco_200g", Distance: 0.20}
|
||||||
|
|
||||||
|
// Lens reads "Parle-G" off the packet and the customer types "Parle G". Both
|
||||||
|
// spellings, and the run-together one, have to reach the biscuit — not the
|
||||||
|
// salted cracker that merely shares a brand. In production "Parle G" returned
|
||||||
|
// "Parle Monaco Classic Regular 200g" at a confident 0.9.
|
||||||
|
func TestLookupMatchesAHyphenatedNameHoweverItIsWritten(t *testing.T) {
|
||||||
|
for _, label := range []string{"Parle G", "Parle-G", "ParleG", "parle g"} {
|
||||||
|
repo := newLookupFixture()
|
||||||
|
repo.vector = []repositories.CatalogueHit{monaco, parleG} // model puts the cracker first
|
||||||
|
repo.text = []repositories.CatalogueHit{monaco, parleG}
|
||||||
|
svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}})
|
||||||
|
|
||||||
|
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: label})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Match == nil {
|
||||||
|
t.Fatalf("%q: a stocked product went unrecognised", label)
|
||||||
|
}
|
||||||
|
if resp.Match.Catalogueid != parleG.ID {
|
||||||
|
t.Fatalf("%q: matched %q (%.3f), want Parle-G", label, resp.Match.ProductName, resp.Match.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal blended scores used to be settled by product name, which let ASCII
|
||||||
|
// decide relevance: a space sorts before a hyphen, so "Parle Monaco …" beat
|
||||||
|
// "Parle-G …". The model's own similarity settles it instead.
|
||||||
|
func TestSortHitsBreaksTiesOnSimilarityNotPunctuation(t *testing.T) {
|
||||||
|
near := parleG
|
||||||
|
near.Distance = 0.10 // the model is surer about this one
|
||||||
|
far := monaco
|
||||||
|
far.Distance = 0.40
|
||||||
|
|
||||||
|
hits := []scoredHit{{CatalogueHit: far, score: 0.9}, {CatalogueHit: near, score: 0.9}}
|
||||||
|
sortHits(hits)
|
||||||
|
if hits[0].ID != near.ID {
|
||||||
|
t.Fatalf("the nearer vector should win a tie, got %q", hits[0].ProductName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A row the model never scored (-1, text-only) ranks behind one it did.
|
||||||
|
textOnly := parleG
|
||||||
|
textOnly.Distance = -1
|
||||||
|
hits = []scoredHit{{CatalogueHit: textOnly, score: 0.9}, {CatalogueHit: far, score: 0.9}}
|
||||||
|
sortHits(hits)
|
||||||
|
if hits[0].ID != far.ID {
|
||||||
|
t.Fatalf("a scored row outranks an unscored one, got %q", hits[0].ProductName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
"nearle/repositories"
|
"nearle/repositories"
|
||||||
"time"
|
"time"
|
||||||
@@ -22,6 +24,21 @@ func NewStockRequestService(repo repositories.StockRequestRepository, productSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *stockRequestService) CreateStockRequest(req *models.StockRequest) error {
|
func (s *stockRequestService) CreateStockRequest(req *models.StockRequest) error {
|
||||||
|
// A request for nothing is not a request.
|
||||||
|
//
|
||||||
|
// Nothing downstream rejected it, so a branch could raise a request for
|
||||||
|
// zero units and it sat in the admin's queue looking exactly like a real
|
||||||
|
// one — and approving it moved no stock, which reads as the ledger being
|
||||||
|
// broken rather than the request being empty. A negative would move stock
|
||||||
|
// the wrong way on receipt, since UpdateStockRequest writes Qty straight
|
||||||
|
// into the ledger as an 'in'.
|
||||||
|
//
|
||||||
|
// Returned as an ordinary error: the controller already reports per-item
|
||||||
|
// reasons, so one bad row in a batch is named and the rest still land.
|
||||||
|
if req.Qty <= 0 {
|
||||||
|
return errors.New("quantity must be more than zero")
|
||||||
|
}
|
||||||
|
|
||||||
return s.repo.CreateStockRequest(req)
|
return s.repo.CreateStockRequest(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
69
utils/geo.go
69
utils/geo.go
@@ -69,23 +69,86 @@ func parseClock(s string) (int, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SearchTokens splits a label into the words worth matching on: lowercased,
|
// SearchTokens splits a label into the words worth matching on: lowercased,
|
||||||
// punctuation stripped, single characters and pack-size noise dropped. "Milk
|
// punctuation stripped, pack-size noise dropped. "Milk Bikis 100g" →
|
||||||
// Bikis 100g" → ["milk", "bikis"]; the size is matched separately, if at all.
|
// ["milk", "bikis"]; the size is matched separately, if at all.
|
||||||
|
//
|
||||||
|
// A single character is kept when it follows a word, because in this
|
||||||
|
// catalogue that character is often the whole product: the "G" in "Parle G",
|
||||||
|
// the "K" in "Special K". Dropping it made "Parle G" score the same against
|
||||||
|
// "Parle-G Original Glucose Biscuits" as against "Parle Monaco Classic", and
|
||||||
|
// the tie went to Monaco. It is still dropped when it stands alone — a
|
||||||
|
// one-letter label is not a search — and bare multipliers ("2 x 50gm") are
|
||||||
|
// never words.
|
||||||
func SearchTokens(label string) []string {
|
func SearchTokens(label string) []string {
|
||||||
var tokens []string
|
var tokens []string
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
|
kept := 0 // multi-character tokens so far: a lone letter needs one
|
||||||
|
var filler []string // packaging words, kept only if nothing else survives
|
||||||
for _, raw := range strings.FieldsFunc(strings.ToLower(label), func(r rune) bool {
|
for _, raw := range strings.FieldsFunc(strings.ToLower(label), func(r rune) bool {
|
||||||
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9')
|
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9')
|
||||||
}) {
|
}) {
|
||||||
if len(raw) < 2 || isPackSize(raw) || seen[raw] {
|
if isPackSize(raw) || seen[raw] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(raw) < 2 && (kept == 0 || isMultiplier(raw)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[raw] = true
|
seen[raw] = true
|
||||||
|
if isPackaging(raw) {
|
||||||
|
filler = append(filler, raw)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(raw) > 1 {
|
||||||
|
kept++
|
||||||
|
}
|
||||||
tokens = append(tokens, raw)
|
tokens = append(tokens, raw)
|
||||||
}
|
}
|
||||||
|
// "Dettol bottle pack" is a scan of Dettol. Only when the label is nothing
|
||||||
|
// but packaging does that packaging become the search.
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
return filler
|
||||||
|
}
|
||||||
return tokens
|
return tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isPackaging is what the label says about the wrapper rather than the
|
||||||
|
// product: "Dettol bottle pack", "Parle G biscuit pack". Lens reads these off
|
||||||
|
// the packet and no catalogue name carries them, so every one of them used to
|
||||||
|
// be a word the row had to contain — and "Dettol bottle pack" found no Dettol
|
||||||
|
// at all. Treated like pack sizes: real words, just not the product's name.
|
||||||
|
func isPackaging(tok string) bool {
|
||||||
|
switch tok {
|
||||||
|
case "pack", "packs", "packet", "packets", "bottle", "bottles",
|
||||||
|
"box", "boxes", "jar", "jars", "tin", "tins", "pouch", "pouches",
|
||||||
|
"carton", "cartons", "sachet", "sachets", "combo", "refill":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isMultiplier is the "x" of "2 x 50gm" and the "n" of a multipack — a single
|
||||||
|
// character that joins sizes rather than naming a product.
|
||||||
|
func isMultiplier(tok string) bool {
|
||||||
|
return tok == "x" || tok == "n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// FoldSeparators turns every run of punctuation into one space, so a label
|
||||||
|
// typed without the brand's own punctuation still matches it: "Parle G" and
|
||||||
|
// "Parle-G" both fold to "parle g". Lens reads letterforms off a packet, and
|
||||||
|
// people type what they see, so the hyphen is not reliably either present or
|
||||||
|
// absent on the way in.
|
||||||
|
func FoldSeparators(s string) string {
|
||||||
|
return strings.Join(strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
|
||||||
|
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9')
|
||||||
|
}), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TightenLabel removes separators outright rather than folding them, catching
|
||||||
|
// the other way people write a hyphenated name: "ParleG" against "Parle-G".
|
||||||
|
func TightenLabel(s string) string {
|
||||||
|
return strings.ReplaceAll(FoldSeparators(s), " ", "")
|
||||||
|
}
|
||||||
|
|
||||||
// isPackSize is "100g", "1kg", "500ml", "2l", "250gm" — a number with a unit
|
// isPackSize is "100g", "1kg", "500ml", "2l", "250gm" — a number with a unit
|
||||||
// glued on, or a bare number.
|
// glued on, or a bare number.
|
||||||
func isPackSize(tok string) bool {
|
func isPackSize(tok string) bool {
|
||||||
|
|||||||
@@ -63,3 +63,68 @@ func TestSearchTokens(t *testing.T) {
|
|||||||
t.Error("pack sizes alone are not searchable")
|
t.Error("pack sizes alone are not searchable")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A single letter is often the whole product name in this catalogue, so it
|
||||||
|
// survives when it follows a word — but not when it stands alone, and not
|
||||||
|
// when it is the multiplier in a pack size.
|
||||||
|
func TestSearchTokensKeepsALetterThatFollowsAWord(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
label string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"Parle G", []string{"parle", "g"}},
|
||||||
|
{"Parle-G", []string{"parle", "g"}},
|
||||||
|
{"Special K Original", []string{"special", "k", "original"}},
|
||||||
|
{"G", nil}, // a letter alone is not a search
|
||||||
|
{"2 x 50gm", nil}, // multiplier and pack size, no words
|
||||||
|
{"Milk Bikis 100g, Britannia (2 x 50gm)", []string{"milk", "bikis", "britannia"}},
|
||||||
|
} {
|
||||||
|
got := SearchTokens(tc.label)
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Fatalf("%q: got %v want %v", tc.label, got, tc.want)
|
||||||
|
}
|
||||||
|
for i := range tc.want {
|
||||||
|
if got[i] != tc.want[i] {
|
||||||
|
t.Fatalf("%q: got %v want %v", tc.label, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFoldAndTightenSeparators(t *testing.T) {
|
||||||
|
if got := FoldSeparators("Parle-G Original"); got != "parle g original" {
|
||||||
|
t.Fatalf("fold: got %q", got)
|
||||||
|
}
|
||||||
|
if FoldSeparators("Parle-G") != FoldSeparators("Parle G") {
|
||||||
|
t.Error("a hyphen and a space are the same separator to us")
|
||||||
|
}
|
||||||
|
if got := TightenLabel("Parle-G"); got != "parleg" {
|
||||||
|
t.Fatalf("tighten: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lens reads the wrapper as well as the product. No catalogue name carries
|
||||||
|
// "bottle" or "pack", so requiring them found no Dettol at all.
|
||||||
|
func TestSearchTokensDropsPackagingWords(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
label string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"Dettol bottle pack", []string{"dettol"}},
|
||||||
|
{"Parle G biscuit pack", []string{"parle", "g", "biscuit"}},
|
||||||
|
{"Milk Bikis pack", []string{"milk", "bikis"}},
|
||||||
|
{"Nescafe jar 50g", []string{"nescafe"}},
|
||||||
|
{"pack", []string{"pack"}}, // nothing else: the wrapper is the search
|
||||||
|
{"combo pack", []string{"combo", "pack"}}, // ditto, both kept
|
||||||
|
} {
|
||||||
|
got := SearchTokens(tc.label)
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Fatalf("%q: got %v want %v", tc.label, got, tc.want)
|
||||||
|
}
|
||||||
|
for i := range tc.want {
|
||||||
|
if got[i] != tc.want[i] {
|
||||||
|
t.Fatalf("%q: got %v want %v", tc.label, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user