orders: share the merchant tidy, and fix two bugs the corpus run found (board 212)
ci / lint-test (push) Successful in 41s
ci / lint-test (push) Successful in 41s
The tidy existed but lived inside the list page, so the DETAIL page never had it: following a row through showed "Apple" becoming "Apple Pty Ltd." Moved to src/lib/merchant-label.ts and applied on both surfaces. Running it over all 1,451 distinct merchant names — rather than over examples — turned up two real defects: Coburger & Co -> "Coburger &" eBay Commerce Australia Pty Ltd. -> "EBay Commerce Australia" The first strips a suffix that is part of the brand and leaves a dangling connector. The second is the capitalisation rule firing on "starts with a lower-case letter" when what it means is "is a bare domain" — mangling a brand that is deliberately lower-cased. Both are now guarded, and re-running over the full corpus reports 170 names tidied and 0 defects. PRESENTATION ONLY. It merges nothing and must never decide two rows are the same merchant. Amazon.in stays Amazon.in — a different marketplace with different currency and geography, not a name variant — and there is a test asserting it can never equal amazon.com.au. Kogan.com, GOG.com, AliExpress.com and Catch.com.au are real brand names containing a TLD and are left alone. Real unification is the merchant-alias bridge, ticket 176, which has already merged what can be merged safely. The five names that read alike after tidying (ebay/eBay/eBay Inc., menulog/Menulog Pty Ltd, deliveroo/Deliveroo, grab/Grab, paypal/PayPal) are genuinely one merchant each, so reading alike is correct. Lower-case platform SLUGS are deliberately not capitalised: "ebay" -> "Ebay" is wrong, and getting it right is the registry's job, not the stylesheet's.
This commit is contained in:
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { tidyMerchant } from "@/lib/merchant-label";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Board 212. Every fixture below is a real `display_name` from order_feed.
|
||||||
|
* The cases that matter most are the ones that must NOT change: this is a
|
||||||
|
* presentational tidy and merging anything here would be a data error wearing
|
||||||
|
* a stylesheet.
|
||||||
|
*/
|
||||||
|
describe("tidyMerchant", () => {
|
||||||
|
it("capitalises a bare domain used as a name", () => {
|
||||||
|
expect(tidyMerchant("amazon.com.au")).toBe("Amazon.com.au");
|
||||||
|
expect(tidyMerchant("cdkeys.com")).toBe("Cdkeys.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("NEVER merges the country marketplaces", () => {
|
||||||
|
// The single most important assertion in this file. Amazon.in is a
|
||||||
|
// different marketplace with different currency and geography, and 289
|
||||||
|
// orders sit behind it.
|
||||||
|
expect(tidyMerchant("Amazon.in")).toBe("Amazon.in");
|
||||||
|
expect(tidyMerchant("amazon.com.au")).not.toBe(tidyMerchant("Amazon.in"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves real brand names that contain a TLD alone", () => {
|
||||||
|
for (const brand of ["Kogan.com", "GOG.com", "AliExpress.com", "Catch.com.au", "GeekBuying.com"]) {
|
||||||
|
expect(tidyMerchant(brand)).toBe(brand);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not uppercase a deliberately lower-cased brand", () => {
|
||||||
|
// The bug the corpus run found: gating on "starts lower-case" rather than
|
||||||
|
// on "is a bare domain" turned this into "EBay Commerce Australia".
|
||||||
|
expect(tidyMerchant("eBay Commerce Australia Pty Ltd.")).toBe("eBay Commerce Australia");
|
||||||
|
expect(tidyMerchant("iRobot Australia Pty Ltd")).toBe("iRobot Australia");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops trailing corporate suffixes", () => {
|
||||||
|
expect(tidyMerchant("Apple Pty Ltd.")).toBe("Apple");
|
||||||
|
expect(tidyMerchant("Microsoft Pty. Limited")).toBe("Microsoft");
|
||||||
|
expect(tidyMerchant("Meta Platforms, Inc.")).toBe("Meta Platforms");
|
||||||
|
expect(tidyMerchant("Domino's Pizza Enterprises Limited")).toBe("Domino's Pizza Enterprises");
|
||||||
|
expect(tidyMerchant("Blinks Labs GmbH")).toBe("Blinks Labs");
|
||||||
|
expect(tidyMerchant("Rasier New Zealand Limited")).toBe("Rasier New Zealand");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips two suffixes when a name carries two", () => {
|
||||||
|
expect(tidyMerchant("Samsung Electronics Co. Ltd.")).toBe("Samsung Electronics");
|
||||||
|
expect(tidyMerchant("DiDi Mobility Information Technology Pte. Ltd."))
|
||||||
|
.toBe("DiDi Mobility Information Technology");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not leave a dangling connector", () => {
|
||||||
|
// 'Co' is part of this brand. Stripping it produced "Coburger &".
|
||||||
|
expect(tidyMerchant("Coburger & Co")).toBe("Coburger & Co");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a name that would be reduced to almost nothing", () => {
|
||||||
|
// "UT" is too short to stand on its own; better scruffy than cryptic.
|
||||||
|
expect(tidyMerchant("UT LLC")).toBe("UT LLC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises whitespace and tolerates junk", () => {
|
||||||
|
expect(tidyMerchant(" Yaffa Media Pty Ltd ")).toBe("Yaffa Media");
|
||||||
|
expect(tidyMerchant("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent", () => {
|
||||||
|
for (const n of ["amazon.com.au", "Apple Pty Ltd.", "Coburger & Co", "Amazon.in"]) {
|
||||||
|
expect(tidyMerchant(tidyMerchant(n))).toBe(tidyMerchant(n));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { use } from "react";
|
import { use } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useOrderDetail } from "@/lib/hooks";
|
import { useOrderDetail } from "@/lib/hooks";
|
||||||
|
import { tidyMerchant } from "@/lib/merchant-label";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One order: what it was, what happened to it, and what paid for it.
|
* One order: what it was, what happened to it, and what paid for it.
|
||||||
@@ -129,7 +130,10 @@ export default function OrderDetailPage({ params }: { params: Promise<{ entityKe
|
|||||||
{o.ordered_at && <> · {dateFmt.format(new Date(o.ordered_at))}</>}
|
{o.ordered_at && <> · {dateFmt.format(new Date(o.ordered_at))}</>}
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-display text-[27px] leading-tight text-zinc-50 my-3 max-w-[26ch] text-balance">
|
<h2 className="font-display text-[27px] leading-tight text-zinc-50 my-3 max-w-[26ch] text-balance">
|
||||||
{o.canonical_name || o.display_name || "Order"}
|
{/* canonical_name is the ORDER title ("DJI Air 3 Fly More Combo") and
|
||||||
|
is left alone; only the display_name fallback is a merchant name
|
||||||
|
and wants tidying. */}
|
||||||
|
{o.canonical_name || tidyMerchant(o.display_name ?? "") || "Order"}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex gap-2 flex-wrap mb-3">
|
<div className="flex gap-2 flex-wrap mb-3">
|
||||||
{o.content_class && KIND_LABEL[o.content_class] && (
|
{o.content_class && KIND_LABEL[o.content_class] && (
|
||||||
@@ -164,7 +168,11 @@ export default function OrderDetailPage({ params }: { params: Promise<{ entityKe
|
|||||||
{o.merchant_name && (
|
{o.merchant_name && (
|
||||||
<div>
|
<div>
|
||||||
<span className="block text-[10px] uppercase tracking-widest text-zinc-500 mb-0.5">Merchant</span>
|
<span className="block text-[10px] uppercase tracking-widest text-zinc-500 mb-0.5">Merchant</span>
|
||||||
{o.merchant_name}
|
{/* Board 212: the list has tidied this since it shipped and the
|
||||||
|
detail page did not, so following a row through showed
|
||||||
|
"Apple" becoming "Apple Pty Ltd." Presentational only —
|
||||||
|
merges nothing, and Amazon.in stays Amazon.in. */}
|
||||||
|
{tidyMerchant(o.merchant_name)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-25
@@ -3,6 +3,7 @@
|
|||||||
import { Suspense, useMemo, useState } from "react";
|
import { Suspense, useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useOrders } from "@/lib/hooks";
|
import { useOrders } from "@/lib/hooks";
|
||||||
|
import { tidyMerchant } from "@/lib/merchant-label";
|
||||||
import type { OrderRow } from "@/lib/order-feed";
|
import type { OrderRow } from "@/lib/order-feed";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,31 +76,6 @@ const KIND_LABEL: Record<string, string> = {
|
|||||||
account_notice: "notice",
|
account_notice: "notice",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Presentational tidy only — this NEVER merges two merchants. The estate holds
|
|
||||||
* "amazon.com.au", "Amazon.in" and "Amazon Services Australia, Inc." as three
|
|
||||||
* distinct entities, and Amazon.in must stay separate: it is a different
|
|
||||||
* marketplace, not a name variant. Actually unifying them is the merchant-alias
|
|
||||||
* bridge (ticket 176). All this does is stop the same entity from looking
|
|
||||||
* scruffy: drop the corporate suffix, and capitalise a name that arrived
|
|
||||||
* lower-cased from a domain.
|
|
||||||
*/
|
|
||||||
const CORP_SUFFIX =
|
|
||||||
/,?\s+(pty\.?\s+ltd\.?|pty\.?\s+limited|p\/l|ltd\.?|limited|inc\.?|llc|pbc|gmbh|b\.?v\.?|s\.?a\.?r\.?l\.?|oü|co\.?)$/i;
|
|
||||||
|
|
||||||
function tidyMerchant(name: string): string {
|
|
||||||
let n = name.replace(/\s+/g, " ").trim();
|
|
||||||
// Strip at most two trailing corporate suffixes ("Pty Ltd." then ",").
|
|
||||||
for (let i = 0; i < 2; i++) {
|
|
||||||
const stripped = n.replace(CORP_SUFFIX, "").trim().replace(/,$/, "");
|
|
||||||
if (stripped === n || stripped.length < 3) break;
|
|
||||||
n = stripped;
|
|
||||||
}
|
|
||||||
// "amazon.com.au" reads as a machine artefact; "Amazon.com.au" reads as a name.
|
|
||||||
if (/^[a-z]/.test(n)) n = n[0].toUpperCase() + n.slice(1);
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
const IN_FLIGHT = new Set(["ordered", "shipped", "out_for_delivery"]);
|
const IN_FLIGHT = new Set(["ordered", "shipped", "out_for_delivery"]);
|
||||||
const REVERSED = new Set(["refunded", "returned", "cancelled"]);
|
const REVERSED = new Set(["refunded", "returned", "cancelled"]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Presentational tidy for a merchant name (board 212).
|
||||||
|
*
|
||||||
|
* PRESENTATION ONLY. It merges nothing and must never be used to decide that
|
||||||
|
* two rows are the same merchant. Real unification is the merchant-alias bridge
|
||||||
|
* (ticket 176, `jobs/merchant_merge.py`), which has already merged what can be
|
||||||
|
* merged safely; what is left on screen is one entity looking scruffy, not two
|
||||||
|
* entities that should be one.
|
||||||
|
*
|
||||||
|
* `Amazon.in` MUST NOT become `amazon.com.au`. They are different marketplaces
|
||||||
|
* with different currency and geography — a genuine country distinction, not a
|
||||||
|
* name variant. `Kogan.com`, `GOG.com`, `AliExpress.com` and `Catch.com.au` are
|
||||||
|
* real brand names that happen to contain a TLD and are already correct.
|
||||||
|
*
|
||||||
|
* Two rules, both mechanical:
|
||||||
|
* 1. drop trailing corporate suffixes
|
||||||
|
* 2. capitalise a name that arrived lower-cased FROM A DOMAIN
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CORP_SUFFIX =
|
||||||
|
/,?\s+(pty\.?\s+ltd\.?|pty\.?\s+limited|pte\.?\s+ltd\.?|pte\.?|p\/l|ltd\.?|limited|inc\.?|llc|pbc|gmbh|b\.?v\.?|s\.?a\.?r\.?l\.?|oü|co\.?)$/i;
|
||||||
|
|
||||||
|
/** A bare domain used as a name: all lower case, and a dotted TLD. */
|
||||||
|
const BARE_DOMAIN = /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(\.[a-z]{2,})?$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stripping must not leave a dangling connector. `Coburger & Co` is a brand
|
||||||
|
* whose last word happens to match the suffix list, and removing it produced
|
||||||
|
* `Coburger &` — visibly broken, and the kind of thing that only shows up when
|
||||||
|
* you run the rule over the real corpus rather than over examples you chose.
|
||||||
|
*/
|
||||||
|
const DANGLING = /(\s[&+-]|\sand)$/i;
|
||||||
|
|
||||||
|
export function tidyMerchant(name: string): string {
|
||||||
|
let n = name.replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
// At most two passes: "Samsung Electronics Co. Ltd." needs Ltd. then Co.
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const stripped = n.replace(CORP_SUFFIX, "").trim().replace(/,$/, "");
|
||||||
|
if (stripped === n || stripped.length < 3 || DANGLING.test(stripped)) break;
|
||||||
|
n = stripped;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "amazon.com.au" reads as a machine artefact; "Amazon.com.au" reads as a
|
||||||
|
// name. Gated on BARE_DOMAIN, not on "starts with a lower-case letter": the
|
||||||
|
// looser test turned `eBay Commerce Australia Pty Ltd.` into `EBay Commerce
|
||||||
|
// Australia`, mangling a brand that is deliberately lower-cased.
|
||||||
|
if (BARE_DOMAIN.test(n)) n = n[0].toUpperCase() + n.slice(1);
|
||||||
|
|
||||||
|
return n;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user