diff --git a/src/lib/server/quotes-search-service.test.ts b/src/lib/server/quotes-search-service.test.ts index 81b1104..df25f43 100644 --- a/src/lib/server/quotes-search-service.test.ts +++ b/src/lib/server/quotes-search-service.test.ts @@ -735,10 +735,20 @@ describe("searchQuotes — pipeline", () => { expect(result.totalReturned).toBe(0); }); - it("ranks exact identifier match above item-only match", async () => { + it("surfaces both identifier and item matches in the result with their scores intact", async () => { // Pushdown: the `~"acme"` filter on `quotes` returns ACME-1; // the `~"acme"` filter on `quote_items` returns the item on // q-item; the expand + items fetches round out the data. + // + // Note: order between the two is NOT a score-rank test anymore + // (the service sorts by `created` desc, not score desc — see the + // "orders hits by created desc" test below). Both fixtures share + // the default `created = "2026-01-01T00:00:00Z"` so the result + // falls through to the id tiebreaker, which puts `q-id` first. + // What this test still locks: + // - both hits are surfaced (no false drop in the pushdown) + // - the per-hit `score` field reflects the identifier > item + // weight ordering (the UI breakdown still works) setStore([ { collection: "quotes", fields: makeQuote({ id: "q-id", quote_number: "ACME-1", customer: "", company: "" }) }, { collection: "quotes", fields: makeQuote({ id: "q-item", quote_number: "MISS", customer: "", company: "" }) }, @@ -754,6 +764,89 @@ describe("searchQuotes — pipeline", () => { expect(result.hits[0].score).toBeGreaterThan(result.hits[1].score); }); + it("orders hits by created desc (newest first), regardless of score", async () => { + // Two quotes both match the query. The newer one has a weaker + // score (item-only match), the older one has a stronger score + // (identifier match). Pre-Sept-2026 the older quote would have + // ranked first on score alone; the office flagged this because + // searching a customer name surfaced their ancient quotes + // ahead of the one they'd literally just sent. Post-fix the + // date wins — the operator's "find me the latest for this + // person" query lands on the most recent matching quote at the + // top regardless of match strength. + setStore([ + { + collection: "quotes", + fields: makeQuote({ + id: "q-old-strong", + quote_number: "ACME-1", + customer: "", + company: "", + created: "2024-01-01T00:00:00Z", + }), + }, + { + collection: "quotes", + fields: makeQuote({ + id: "q-new-weak", + quote_number: "MISS", + customer: "", + company: "", + created: "2026-08-01T00:00:00Z", + }), + }, + { + collection: "quote_items", + fields: makeItem({ id: "qi1", quote: "q-new-weak", description: "acme widget" }), + }, + ]); + + const result = await searchQuotes({ query: "ACME" }); + expect(result.hits).toHaveLength(2); + expect(result.hits[0].quote.id).toBe("q-new-weak"); // newest first + expect(result.hits[1].quote.id).toBe("q-old-strong"); + // The score breakdown is still per-hit and still reflects the + // weight ordering — the UI's "why this match" affordance + // keeps working even though score no longer decides order. + const oldScore = result.hits[1].score; + const newScore = result.hits[0].score; + expect(oldScore).toBeGreaterThan(newScore); + }); + + it("breaks ties on identical created timestamps by quote id asc (stable pagination)", async () => { + // Batch-import case: two quotes stamped with the same `created` + // second. The id tiebreaker keeps the order deterministic so + // paginating through the search returns the same rows on every + // page-load instead of shuffling between requests. + setStore([ + { + collection: "quotes", + fields: makeQuote({ + id: "q-alpha", + quote_number: "ACME-A", + customer: "", + company: "", + created: "2026-08-01T00:00:00Z", + }), + }, + { + collection: "quotes", + fields: makeQuote({ + id: "q-bravo", + quote_number: "ACME-B", + customer: "", + company: "", + created: "2026-08-01T00:00:00Z", + }), + }, + ]); + + const result = await searchQuotes({ query: "ACME" }); + expect(result.hits).toHaveLength(2); + expect(result.hits[0].quote.id).toBe("q-alpha"); // id asc + expect(result.hits[1].quote.id).toBe("q-bravo"); + }); + it("chunks candidate-id fetches into <=80-id OR-chains (no single large filter)", async () => { // Build a candidate set larger than ID_FILTER_CHUNK_SIZE (80) so // the chunkedFilterFetch path is exercised. Each candidate must diff --git a/src/lib/server/quotes-search-service.ts b/src/lib/server/quotes-search-service.ts index 5a37fc4..5932d75 100644 --- a/src/lib/server/quotes-search-service.ts +++ b/src/lib/server/quotes-search-service.ts @@ -44,8 +44,13 @@ * before. PB-side `~` filters are conservative (any-field * substring); the in-memory scorer upgrades exact-prefix and * identifier hits above body matches and gives us a sort order. - * 7. Sort by score desc (id asc as stable tiebreak), paginate, - * return top N + per-tab match counts. + * 7. Sort by `created` desc (newest first; quote id as stable + * tiebreak), paginate, return top N + per-tab match counts. + * Score is still computed per-hit and surfaced in the response + * for the UI's "why this match" breakdown, but the order within + * the result set is purely recency — the operator wants the + * most recent quote for their customer on top, regardless of + * how strongly an older quote matched the query. * * Why pushdown matters: the live PB has ~8.5k quotes, each with up * to dozens of items (and supplier expand). Pulling all of them on @@ -1242,12 +1247,25 @@ export async function searchQuotes( if (t) perTab[t] = (perTab[t] ?? 0) + 1; } - // 5. Sort by score desc, then by quote id (stable) for deterministic - // pagination. Score is the primary key; created is intentionally - // NOT used as a tiebreak so the operator sees the strongest - // match first, not the newest. + // 5. Sort by `created` descending (newest first). The relevance + // score is still computed per-hit and surfaced in the response + // (so the UI can show "why this match" with the existing + // score breakdown), and the score still decides which quotes + // qualify — only candidates with `breakdown.raw > 0` make it + // into the result set. What changed (2026-09-08): the order + // within that set is no longer score-desc. Operators + // complained that an exact quote_number hit from 2025 was + // beating a more recent customer-name match for the same + // person — i.e. "the most recent quote for that person" was + // buried in the middle of the result list. Now the newest + // qualifying quote is always on top, regardless of how + // strongly an older quote matched. Quote id is the stable + // secondary tiebreaker so two quotes sharing a `created` + // timestamp (e.g. a batch-import) paginate deterministically. scored.sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; + if (b.quote.created !== a.quote.created) { + return b.quote.created.localeCompare(a.quote.created); + } return a.quote.id < b.quote.id ? -1 : a.quote.id > b.quote.id ? 1 : 0; });