189 lines
7.6 KiB
Markdown
189 lines
7.6 KiB
Markdown
# Receipt parsing (implementation)
|
||
|
||
This is the working guide for Groci's receipt parser. Use it when a new
|
||
shop's receipt does not parse well.
|
||
|
||
The original design notes live in [`receipt-parsing-spec.md`](../receipt-parsing-spec.md).
|
||
Keywords and regexes live in [`receipt-parsing-keywords.json`](../receipt-parsing-keywords.json)
|
||
and are bundled as a Flutter asset.
|
||
|
||
---
|
||
|
||
## What the parser produces
|
||
|
||
For each receipt:
|
||
|
||
| Field | Meaning |
|
||
|---|---|
|
||
| `items[].name` | Product name |
|
||
| `items[].quantity` | Pack count when `N X unit` was found (otherwise omitted) |
|
||
| `items[].unitPrice` | Price of one pack when quantity is set |
|
||
| `items[].discount` | Negative amount folded into the line (promos) |
|
||
| `items[].price` | **Line total after discount** (this is what the trip saves) |
|
||
| `store` | Best-effort store from the header + `known_stores` |
|
||
| `receiptTotal` | First grand-total amount after the items |
|
||
| `totalDiscount` | Receipt-level `Totaal korting` (and similar) |
|
||
| `validationPassed` | Item sum matches `receiptTotal` within €0.01, item count matches when printed, and no line needs manual review |
|
||
| `items[].sourceBounds` | Normalized rect on the receipt photo for tap-to-highlight in Review |
|
||
| `items[].needsManualReview` | Parser could not resolve price/qty confidently |
|
||
| `expectedItemCount` | From `Aantal Artikel`-style lines when found |
|
||
| `ocrImageWidth` / `ocrImageHeight` | Image size used to normalize bounds |
|
||
|
||
If validation fails, Review shows a banner asking you to check items before saving.
|
||
|
||
---
|
||
|
||
## Pipeline
|
||
|
||
### Primary: spatial (ML Kit bounding boxes)
|
||
|
||
Used automatically after scanning a receipt photo.
|
||
|
||
1. **Extract elements** from ML Kit `RecognizedText` (blocks → lines → elements), keeping each element's pixel bounding box.
|
||
2. **Cluster rows** by Y-center (threshold ≈ 1.2× median element height — see `spatial_parsing` in the JSON config).
|
||
3. **Classify rows** with column logic: right ~25% of content width is the price column; pure `N X price` rows are quantity modifiers (checked first).
|
||
4. **Build items** with a one-row lookahead buffer (Jumbo split layout + Kruidvat inline layout).
|
||
5. **Validate** totals, printed item count, and manual-review flags.
|
||
|
||
Implementation: `ReceiptOcr`, `ReceiptRowBuilder`, `ReceiptSpatialParser` in `lib/services/`.
|
||
|
||
### Fallback: flat text
|
||
|
||
`ReceiptParser.parseReceipt(String)` for tests and when only flattened OCR text exists.
|
||
|
||
1. **Preprocess** each OCR line: trim, collapse spaces, drop empty lines.
|
||
2. **Choose a layout**
|
||
- **Sequential** (default): name, price, quantity and discount appear in
|
||
reading order. Jumbo (printed), Lidl, Kruidvat, World Toko.
|
||
- **Column OCR fallback:** ML Kit sometimes dumps every name, then every
|
||
amount. Detected when there is a run of 4+ price-only lines *and* enough
|
||
name-like lines.
|
||
3. **Classify** each line (first match wins) using the keyword JSON.
|
||
4. **Build items** (pending name, then quantity/discount/price).
|
||
5. **Validate** against the receipt total when one was found.
|
||
|
||
Do **not** treat weight in the name (`900g`, `500ml`) as quantity. Only `N X`
|
||
next to a price is a quantity.
|
||
|
||
---
|
||
|
||
## Line types
|
||
|
||
Priority is the same as the spec:
|
||
|
||
1. Separators / column headers (`OMSCHRIJVING`, `Artikel`, `Prijs`, …)
|
||
2. Tax / BTW tables (`BTW`, `Bedr.Excl`, `B 9%`)
|
||
3. Payment (`Betaald`, `VISA`, `PIN`, …) — also matches OCR `Betaal d:`
|
||
4. Noise / footer (URLs, opening hours, thank-you lines)
|
||
5. Receipt-level discount total (`Totaal korting`)
|
||
6. Subtotal / tax-inclusive total (ignored for items)
|
||
7. Grand total (`Totaal`, `Total`) — stops sequential item parsing when an
|
||
amount is on the same line
|
||
8. Discount (`ACTIE`, `KORTING`, `In prijs verlaagd`, or a negative amount)
|
||
9. Quantity modifier (`2 X 2,79`, optional `PER STUK`, optional line total)
|
||
10. Item (name + price at end; optional tax letter `B`/`C` is stripped)
|
||
11. Price-only
|
||
12. Name-only (starts a pending item — Jumbo multi-pack)
|
||
|
||
### Quantity variants
|
||
|
||
| Variant | Example | Store |
|
||
|---|---|---|
|
||
| **A** — name, then `N X unit` | `AARDBEIEN` / `2 X 3,99` | Jumbo, World Toko |
|
||
| **B** — name and `N X unit` on one line | `ZEEPTABLET NEUTRAL 2 X 3,99 PER STUK 7,98` | Kruidvat |
|
||
|
||
The JSON standalone quantity regex is extended in code so a right-hand
|
||
line total is allowed: `2 X 2,79 5,58`.
|
||
|
||
### Discounts
|
||
|
||
A discount is attached to the **current / previous item** and subtracted from
|
||
`price`. Receipt-wide totals (`Totaal korting`) are not products.
|
||
|
||
---
|
||
|
||
## How to add a new shop
|
||
|
||
Work in this order. You usually only need step 1.
|
||
|
||
### 1. Keywords (`receipt-parsing-keywords.json`)
|
||
|
||
| Add to | When |
|
||
|---|---|
|
||
| `known_stores.list` | Header contains a new banner name |
|
||
| `column_header_keywords.list` | New table titles (`Omschrijving`, `Amount`, …) |
|
||
| `discount_keywords.list` | Promo wording (`Bonus`, `Sparing`, …) |
|
||
| `total_keywords.*` | Other words for subtotal / grand total |
|
||
| `payment_keywords.list` | Card brands, `Betaald met …` |
|
||
| `tax_breakdown_keywords.list` | VAT table labels |
|
||
| `noise_footer_keywords.list` | Loyalty, hours, slogans that were parsed as items |
|
||
| `quantity_modifier_pattern` | Only if `N X` looks different (`2x`, `2 *`, `à`) |
|
||
|
||
Matching rules implemented in code (not only the JSON comment):
|
||
|
||
- Keywords of **3 characters or fewer** use a **word boundary** (`PIN` must
|
||
not match `SPINAZIE`).
|
||
- Column headers and item-count lines must **be the whole line** (so `Prijs`
|
||
as a header does not hide Lidl's `In prijs verlaagd`).
|
||
|
||
After editing the JSON, rebuild the app (it is loaded as an asset at startup).
|
||
Tests load the same file from the project root.
|
||
|
||
### 2. Store quirks table
|
||
|
||
Add a row here **and** in `receipt-parsing-spec.md` section 6:
|
||
|
||
| Store | Layout | Notes |
|
||
|---|---|---|
|
||
| Jumbo | A | `ACTIE <name>` + negative amount. Tax letter `B`/`C` far right. OCR may split names vs amounts → column fallback. |
|
||
| Lidl | sequential | `In prijs verlaagd -0,50`. Ignore `Bedr.Excl` / `B 9%` rows. |
|
||
| Kruidvat | B | `N X price PER STUK` on the product line. `KORTING …` may be one line later. |
|
||
| World Toko | A / simple | `Artikel` / `Prijs` headers. Weight in the name is not quantity. |
|
||
| *(new shop)* | A / B / columns | Short description of the odd lines |
|
||
|
||
### 3. Canonical store name
|
||
|
||
If OCR should pre-select the supermarket on Review:
|
||
|
||
- Add the banner string to `known_stores.list`
|
||
- Add an alias in `lib/utils/supermarkets.dart` (`kSupermarketAliases`)
|
||
- Map the uppercase banner in `ReceiptParser._canonicalStore`
|
||
|
||
### 4. Fixture test
|
||
|
||
1. Scan a real receipt, copy **Raw OCR text** from Review.
|
||
2. Add a test in `test/receipt_parser_test.dart` with that dump.
|
||
3. Assert product names, a couple of prices, quantity lines, and that headers /
|
||
totals / payment lines are **not** items.
|
||
|
||
Keep the raw dump in the test (or a `test/fixtures/` file) so the next person
|
||
can see the real layout.
|
||
|
||
### 5. Only then change parser code
|
||
|
||
Change `lib/services/receipt_parser.dart` if the new layout is a **new
|
||
structure** (not just new words): extra columns, quantity written as
|
||
`2 stuks à 1,50`, discounts *above* the product, etc.
|
||
|
||
Prefer a small, documented branch over a one-off special case for one shop.
|
||
|
||
---
|
||
|
||
## Files
|
||
|
||
| File | Role |
|
||
|---|---|
|
||
| `receipt-parsing-keywords.json` | Editable keyword / regex config |
|
||
| `receipt-parsing-spec.md` | Design: classification order, item algorithm |
|
||
| `lib/services/receipt_parse_config.dart` | Loads JSON, keyword matching |
|
||
| `lib/services/receipt_parser.dart` | Classify → items → validate |
|
||
| `test/receipt_parser_test.dart` | Layout fixtures |
|
||
|
||
---
|
||
|
||
## Out of scope (parser)
|
||
|
||
- Product categories
|
||
- Matching names to the existing catalog
|
||
- Translating receipts; add another keyword list if you need a second language
|