intake: match merchants on whole words, not substrings
merchantKey("Bigals Pizza") contains "iga", so GROCERY_PATTERN filed a pizza
shop as a grocer and merchantMatches matched it against every IGA on the same
date and total. Short names in a substring list collide with ordinary words,
and the collision reads as a correct classification rather than a bug.
GROCERY_PATTERN is now a word-boundary alternation; merchantMatches asks
whether one name's words appear as a contiguous run inside the other's, which
keeps Coles <-> Coles (Wyndham Vale) and Order - Coles (Wyndham Vale).
Also collapses the third PLATFORM_LABEL copy: order-ingestion's was exported
and read nowhere, order-details.tsx had its own, and the intake one — the only
live copy — was missing `uber`. One map, in intake-rules.
This commit is contained in:
@@ -13,12 +13,9 @@ import {
|
|||||||
type OrderReceipt,
|
type OrderReceipt,
|
||||||
type OrderRating,
|
type OrderRating,
|
||||||
} from "@/lib/hooks";
|
} from "@/lib/hooks";
|
||||||
|
// One platform vocabulary, shared with the intake cards — three copies had
|
||||||
const PLATFORM_LABEL: Record<string, string> = {
|
// already drifted apart (the intake one was missing `uber`).
|
||||||
doordash: "DoorDash",
|
import { platformLabel } from "@/lib/intake-rules";
|
||||||
ubereats: "Uber Eats",
|
|
||||||
uber: "Uber",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Who records verdicts. A two-person household with one primary user: the
|
* Who records verdicts. A two-person household with one primary user: the
|
||||||
@@ -99,7 +96,7 @@ export function OrderDetails({
|
|||||||
<p className="text-xs text-zinc-500">
|
<p className="text-xs text-zinc-500">
|
||||||
Order details
|
Order details
|
||||||
{receipt.platform && (
|
{receipt.platform && (
|
||||||
<span className="ml-1.5 text-zinc-400">{PLATFORM_LABEL[receipt.platform] ?? receipt.platform}</span>
|
<span className="ml-1.5 text-zinc-400">{platformLabel(receipt.platform)}</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{receipt.card_last4 && (
|
{receipt.card_last4 && (
|
||||||
|
|||||||
+32
-8
@@ -43,6 +43,7 @@ export function platformForSource(source: IntakeSource): string | null {
|
|||||||
export const PLATFORM_LABEL: Record<string, string> = {
|
export const PLATFORM_LABEL: Record<string, string> = {
|
||||||
ubereats: "Uber Eats",
|
ubereats: "Uber Eats",
|
||||||
doordash: "DoorDash",
|
doordash: "DoorDash",
|
||||||
|
uber: "Uber",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function platformLabel(platform: string | null): string {
|
export function platformLabel(platform: string | null): string {
|
||||||
@@ -51,10 +52,16 @@ export function platformLabel(platform: string | null): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A small pattern list, not an exact lookup (plan §2, Codex #6). Uber Eats prints
|
* A small pattern list, not an exact lookup (plan §2, Codex #6). Uber Eats prints
|
||||||
* `Coles (Wyndham Vale)`, a PDF may print `Woolworths Group Limited`; substring on
|
* `Coles (Wyndham Vale)`, a PDF may print `Woolworths Group Limited`; a
|
||||||
* the normalised name covers both without a merchant master-data subsystem.
|
* word-boundary alternation on the normalised name covers both without a
|
||||||
|
* merchant master-data subsystem.
|
||||||
|
*
|
||||||
|
* The boundaries are load-bearing, not tidiness: a bare substring made
|
||||||
|
* `Bigals Pizza` a grocer, because "b-IGA-ls" contains `iga`. Short names in a
|
||||||
|
* substring list collide with ordinary words, and the collision reads as a
|
||||||
|
* correct classification.
|
||||||
*/
|
*/
|
||||||
export const GROCERY_PATTERN = /coles|woolworths|woolies|aldi|iga|foodworks|costco/i;
|
export const GROCERY_PATTERN = /\b(coles|woolworths|woolies|aldi|iga|foodworks|costco)\b/i;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The normalised merchant used for matching and for the remembered-kind table.
|
* The normalised merchant used for matching and for the remembered-kind table.
|
||||||
@@ -219,10 +226,27 @@ export function toLineItems(items: ExtractedItem[]): StoredLineItem[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Does the stored merchant name denote the extracted one? Exact key, or one contains the other. */
|
/** Is `needle` a contiguous run of whole words inside `hay`? */
|
||||||
|
function containsWordRun(hay: string[], needle: string[]): boolean {
|
||||||
|
if (!needle.length || needle.length > hay.length) return false;
|
||||||
|
for (let i = 0; i + needle.length <= hay.length; i++) {
|
||||||
|
if (needle.every((w, j) => hay[i + j] === w)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does the stored merchant name denote the extracted one? One name's words must
|
||||||
|
* appear as a contiguous run inside the other's — so `Coles` matches
|
||||||
|
* `Coles (Wyndham Vale)` and `Order - Coles (Wyndham Vale)`, which is spelling,
|
||||||
|
* not widening.
|
||||||
|
*
|
||||||
|
* Whole words, never a raw substring: `merchantKey("Bigals Pizza")` contains
|
||||||
|
* `iga`, so a pizza shop matched every IGA on the same date and total.
|
||||||
|
*/
|
||||||
export function merchantMatches(stored: string | null | undefined, extracted: string | null | undefined): boolean {
|
export function merchantMatches(stored: string | null | undefined, extracted: string | null | undefined): boolean {
|
||||||
const a = merchantKey(stored);
|
const a = merchantKey(stored).split(" ").filter(Boolean);
|
||||||
const b = merchantKey(extracted);
|
const b = merchantKey(extracted).split(" ").filter(Boolean);
|
||||||
if (!a || !b) return false;
|
if (!a.length || !b.length) return false;
|
||||||
return a === b || a.includes(b) || b.includes(a);
|
return containsWordRun(a, b) || containsWordRun(b, a);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,6 @@ export const CUTOVER_DATE = "2026-01-09";
|
|||||||
*/
|
*/
|
||||||
export const DEFAULT_OWNER_ID = 1;
|
export const DEFAULT_OWNER_ID = 1;
|
||||||
|
|
||||||
/** Human labels for the platform a receipt came from. */
|
|
||||||
export const PLATFORM_LABEL: Record<ParsedOrder["platform"], string> = {
|
|
||||||
doordash: "DoorDash",
|
|
||||||
ubereats: "Uber Eats",
|
|
||||||
uber: "Uber",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transaction description.
|
* Transaction description.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user