feat(quotes): add person_hint for customer resolution in forwarded emails
Introduces the `person_hint` field to the `EmailIntakePayload`, allowing the system to resolve the actual customer when a rep forwards an email. The `findOrCreateCustomer` function now prioritizes `person_hint` over the sender's email, enabling a more accurate attribution of quotes. This change includes a case-insensitive substring match with a token-drop fallback for multi-token names, ensuring better matching against existing customer records. Additionally, new unit tests have been added to validate this functionality, covering various scenarios including token-drop behavior and ensuring the correct customer is linked when a rep forwards an RFQ. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
dc84ce0f30
commit
cfd3e3e6be
5 changed files with 512 additions and 48 deletions
163
docs/api-reference/orders-create.md
Normal file
163
docs/api-reference/orders-create.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# WFDASH API — Create Order (POST /api/v1/orders/create)
|
||||
|
||||
> **One payload, one call.** The full order header + items array goes
|
||||
> in a single request body. There is no `/api/v1/orders` POST —
|
||||
> `/orders` is GET-only.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST /api/v1/orders/create
|
||||
```
|
||||
|
||||
Bearer-token auth via `AGENT_API_KEY` header. **Hermes drops
|
||||
`AGENT_API_KEY` on direct REST calls** — use the MCP wrapper
|
||||
`mcp__wfdash-mcp__orders__create` with the same body shape.
|
||||
|
||||
`runtime = "nodejs"` (PocketBase admin auth requires Node).
|
||||
|
||||
---
|
||||
|
||||
## Request body
|
||||
|
||||
```json
|
||||
{
|
||||
"customer_id": "<15-char PB id>", // required
|
||||
"company_id": "<15-char PB id>", // required
|
||||
"notes": "string?", // order-level notes
|
||||
"contact_name":"string?", // operator-readable contact
|
||||
"description": "string?", // short description of the order
|
||||
"email_message_id": "string?", // dedup key
|
||||
"rep": "string?", // sales rep free-text
|
||||
"items": [
|
||||
{
|
||||
"description": "string", // required, trimmed
|
||||
"quantity": 1, // default 1; must be > 0 when supplied
|
||||
"cost_price": 123.45,
|
||||
"markup": 25,
|
||||
"selling_price": 154.31, // optional
|
||||
"sku": "string?",
|
||||
"unit": "EA",
|
||||
"supplier_id": "<15-char PB id?>",
|
||||
"notes": "string?"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Field rules
|
||||
|
||||
- **`customer_id`, `company_id`** — must resolve in PB. Free-text
|
||||
names are **not** accepted on this endpoint (use the MCP `orders.create`
|
||||
wrapper if you need name-resolution; the REST handler requires ids).
|
||||
- **`items[]`** — non-empty, every item needs `description`.
|
||||
- **`selling_price` auto-derive** — when `cost_price` and `markup` are
|
||||
both supplied and `selling_price` is omitted, it auto-derives as
|
||||
`cost_price * (1 + markup / 100)` rounded to 2 dp. Pass
|
||||
`selling_price` explicitly to override.
|
||||
- **`email_message_id`** — optional idempotency key. If an order with
|
||||
the same `email_message_id` already exists, the response returns the
|
||||
existing record with `duplicate: true` and HTTP **200** instead of
|
||||
201. Safe to retry.
|
||||
- **Per-row independence** — each item creates one `order_item` row
|
||||
with `item_status = "pending"` and `drv_status = "unassigned"`. One
|
||||
item's secondary-field patch failure does **not** abort the rest of
|
||||
the call; failures land in `items_failed[]` with the partial-success
|
||||
shape.
|
||||
|
||||
---
|
||||
|
||||
## Response
|
||||
|
||||
### 201 — created
|
||||
|
||||
```json
|
||||
{
|
||||
"order": { /* full PB order record */ },
|
||||
"order_items_collection": "<relation-id>",
|
||||
"items_created": [ { "id": "...", "description": "..." } ],
|
||||
"items_failed": []
|
||||
}
|
||||
```
|
||||
|
||||
### 200 — duplicate (`email_message_id` matched)
|
||||
|
||||
Same shape as 201, plus `"duplicate": true`. Nothing new was written.
|
||||
|
||||
### 400 — validation failure
|
||||
|
||||
```json
|
||||
{ "error": "customer_id is required" }
|
||||
{ "error": "items must be a non-empty array" }
|
||||
```
|
||||
|
||||
### 404 — not found
|
||||
|
||||
```json
|
||||
{ "error": "Requested resource not found..." }
|
||||
```
|
||||
|
||||
`customer_id` or `company_id` doesn't resolve in PB.
|
||||
|
||||
### 500 — server / PB write failure
|
||||
|
||||
```json
|
||||
{ "error": "<PocketBase error message>" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common workflows
|
||||
|
||||
### Walk-in / counter sale
|
||||
|
||||
No upstream quote. Customer walks up, picks items, you write the
|
||||
order straight from the till:
|
||||
|
||||
```bash
|
||||
curl -X POST https://wfdash.example/api/v1/orders/create \
|
||||
-H "Authorization: Bearer $AGENT_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"customer_id": "<customer-id>",
|
||||
"company_id": "<company-id>",
|
||||
"notes": "Counter sale — 2026-08-27",
|
||||
"items": [
|
||||
{ "description": "SKF 6205 bearing", "quantity": 4,
|
||||
"cost_price": 85, "markup": 30 }
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Re-create from a quote
|
||||
|
||||
**Don't** use this endpoint — use
|
||||
`POST /api/v1/orders/from-quote` (MCP: `orders.create_from_quote`).
|
||||
It clones the matched quote into a fresh `ORD-YYYY-MM-NNN` order,
|
||||
inheriting customer / company / rep / letterhead / line items.
|
||||
|
||||
### Append to an existing order
|
||||
|
||||
There **is** a separate endpoint:
|
||||
`POST /api/v1/orders/{id}/items` (MCP: `order_items.create`). But it
|
||||
**refuses with 409 `order_locked`** the moment any line on the order
|
||||
is past `pending`. Use it only on fresh orders — for any order that
|
||||
has a PO in flight, you must `orders.split` first.
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Passing the order header without `items[]`** → 400. The handler
|
||||
does not create empty orders.
|
||||
- **Passing `quantity: 0` or negative** → rejected per item by the
|
||||
service layer; the row lands in `items_failed[]` with
|
||||
`validation_error`.
|
||||
- **Forgetting to send `AGENT_API_KEY`** → 401 from
|
||||
`assertAgentAuth`. From Hermes, use `mcp__wfdash-mcp__orders__create`
|
||||
instead — the MCP layer injects the key.
|
||||
- **Reusing `email_message_id` for genuinely different orders** →
|
||||
the second call returns the first order with `duplicate: true`.
|
||||
Don't reuse the key; generate a new one per logical order.
|
||||
179
docs/api-reference/quote-create-and-customer-lookup.md
Normal file
179
docs/api-reference/quote-create-and-customer-lookup.md
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
# WFDASH API — Quote Create + Customer Lookup Reference
|
||||
|
||||
> What the WFDASH REST/MCP API actually exposes today for the two
|
||||
> tasks you keep hitting during intake: creating a quote, and
|
||||
> finding an existing customer.
|
||||
|
||||
---
|
||||
|
||||
## Quote create — three flavours
|
||||
|
||||
There are three different quote-creation endpoints. Pick by **where
|
||||
the data came from**, not by what feels closest:
|
||||
|
||||
### 1. `quotes.create` (walk-in) — **the one for your intake**
|
||||
|
||||
```
|
||||
POST /api/v1/quotes/create
|
||||
```
|
||||
|
||||
- Use when: there's no inbound RFQ email. Operator types it in, you
|
||||
extracted items from a PDF / phone-call / WhatsApp, or you're
|
||||
driving the quote from the in-store counter PWA.
|
||||
- Body has `customer` (id OR name), `company` (id OR name), and
|
||||
`items[]`. **Names are resolved case-insensitive exact** — if
|
||||
there's no match, a new customer / company row is created with
|
||||
empty defaults. So passing a name is safe; passing the wrong name
|
||||
silently creates a duplicate.
|
||||
- Returns `{ quote, customer, company, items, quote_number }` on 201.
|
||||
- Status default is `"new"`. `quote_number` is auto-generated unless
|
||||
you supply one (operator-supplied conflicts return 409).
|
||||
- MCP wrapper: `mcp__wfdash-mcp__quotes__create`. Same body shape.
|
||||
|
||||
### 2. `quotes.ingest_from_email` (inbound RFQ email)
|
||||
|
||||
```
|
||||
POST /api/v1/quotes/ingest-from-email
|
||||
```
|
||||
|
||||
- Use when: the customer actually emailed in. Returns immediately
|
||||
with `ai_status="pending"`; the background processor runs Claude
|
||||
against the body + attachments to extract items. Don't extract
|
||||
items yourself — let the pipeline do it.
|
||||
- MCP wrapper: `mcp__wfdash-mcp__quotes__ingest_from_email`.
|
||||
|
||||
### 3. `quotes.create_from_email` (legacy, **avoid**)
|
||||
|
||||
```
|
||||
POST /api/v1/quotes/create-from-email
|
||||
```
|
||||
|
||||
- Caller passes a pre-extracted `extracted_items[]`. Only use this if
|
||||
you're calling on behalf of a parser that already did the
|
||||
extraction. **Wrong tool if you don't have an inbound email** —
|
||||
it requires `subject` + parsed body and silently rewrites
|
||||
`email_message_id` to a `synth-…@wfdash.local` sentinel.
|
||||
- MCP wrapper: `mcp__wfdash-mcp__quotes__create_from_email`.
|
||||
|
||||
---
|
||||
|
||||
## Customer lookup — fuzzy search by name / company
|
||||
|
||||
### `customers.list` — substring search
|
||||
|
||||
```
|
||||
GET /api/v1/customers
|
||||
```
|
||||
|
||||
Query params:
|
||||
|
||||
| Param | Type | What it does |
|
||||
|-----------|------|--------------|
|
||||
| `search` | string | Substring match on **customer name / email / phone**, OR'd together; also matches **rep name / rep email** (the user that owns this customer). One query covers all four fields. Case-sensitive PB `~` operator. |
|
||||
| `rep` | string | Exact filter on the rep relation. Accepts a 15-char PB user id OR a user name / email (case-insensitive). |
|
||||
| `page` | number | 1-based, default 1 |
|
||||
| `perPage` | number | default 50, hard cap 100 |
|
||||
| `sort` | string | PB sort expression, default `-created` |
|
||||
| `expand` | string | Comma-separated, e.g. `"company,rep"` to inline relations |
|
||||
|
||||
Returns `{ items, totalItems, totalPages, page, perPage }`.
|
||||
|
||||
MCP wrapper: `mcp__wfdash-mcp__customers__list`. Same params.
|
||||
|
||||
### `customers.get` — fetch by id
|
||||
|
||||
```
|
||||
GET /api/v1/customers/{id}
|
||||
```
|
||||
|
||||
For when you already have the PB id and just need the full row +
|
||||
relations.
|
||||
|
||||
MCP wrapper: `mcp__wfdash-mcp__customers__get`.
|
||||
|
||||
### What we DO NOT have today
|
||||
|
||||
- **No direct "filter customers by company name"** on `customers.list`.
|
||||
The `search` param doesn't search across the `company` relation.
|
||||
Two workarounds:
|
||||
1. Pass `expand: "company"` on the list call, then filter the
|
||||
returned `items[]` client-side by company name.
|
||||
2. First `companies.list({search: "<name>"})` to get the company
|
||||
id, then iterate the matching customers client-side. (The
|
||||
`customers.list` `search` filter does not accept a company
|
||||
relation id today.)
|
||||
|
||||
- **No dedicated `customers.lookup` endpoint** — `customers.list`
|
||||
with a small `perPage` (e.g. 5) is the closest equivalent.
|
||||
|
||||
- **No fuzzy / Levenshtein matching.** It's PB's literal substring
|
||||
`~` operator. "AB" won't match "ABC Trading" if you typed it
|
||||
wrong-case, and partial-token matches like "Bear" against "Bearings
|
||||
Co" will work — but "Bearings" against "Bear" works too (substring
|
||||
is bidirectional). Typos are not forgiven.
|
||||
|
||||
---
|
||||
|
||||
## The intake pattern that works
|
||||
|
||||
Given what we have, the cleanest customer-resolution flow at intake
|
||||
is:
|
||||
|
||||
```ts
|
||||
// 1. Try the most specific lookup first.
|
||||
const hit = await customers.list({
|
||||
search: customerName,
|
||||
expand: "company",
|
||||
perPage: 5,
|
||||
});
|
||||
|
||||
// 2. If the caller ALSO gave a company name, narrow client-side.
|
||||
const filtered = companyName
|
||||
? hit.items.filter(c => c.expand?.company?.company === companyName)
|
||||
: hit.items;
|
||||
|
||||
// 3. If 0 hits → create via customers.create (auto-creates company
|
||||
// if company name doesn't resolve either). Use quotes.create with
|
||||
// the names directly — it has the same auto-create behaviour, so
|
||||
// you don't need a separate customers.create call unless you want
|
||||
// to backfill email/phone/etc.
|
||||
```
|
||||
|
||||
For quote creation specifically, **just pass `customer` and `company`
|
||||
as names to `quotes.create`** — the service layer resolves them by
|
||||
case-insensitive exact match and creates missing rows. You only need
|
||||
the lookup step when:
|
||||
|
||||
- You're filling in `email` / `phone` from a contact form.
|
||||
- You want to verify the customer exists before showing the operator
|
||||
a "is this the right John Smith?" confirmation.
|
||||
- You're merging two customer records.
|
||||
|
||||
---
|
||||
|
||||
## Companies (related, since you mentioned name + company)
|
||||
|
||||
### `companies.list` — substring search
|
||||
|
||||
```
|
||||
GET /api/v1/companies
|
||||
```
|
||||
|
||||
Query params: `page`, `perPage`, `search` (substring on
|
||||
`company.company` / address / vendor), `sort`. Same shape as
|
||||
`customers.list`.
|
||||
|
||||
MCP wrapper: `mcp__wfdash-mcp__companies__list`.
|
||||
|
||||
### `companies.get` — fetch by id
|
||||
|
||||
```
|
||||
GET /api/v1/companies/{id}
|
||||
```
|
||||
|
||||
MCP wrapper: `mcp__wfdash-mcp__companies__get`.
|
||||
|
||||
### `companies.create` / `companies.update`
|
||||
|
||||
Free-text company name, optional address / vendor / coordinates.
|
||||
Same auto-resolve-and-create-on-no-match behaviour as customers.
|
||||
|
|
@ -97,6 +97,18 @@ export interface EmailIntakePayload {
|
|||
attachments?: EmailIntakeAttachment[];
|
||||
/** Optional override for company matching — used by the AI extraction layer. */
|
||||
company_hint?: string;
|
||||
/**
|
||||
* Optional override for customer matching — used when the inbound email
|
||||
* is forwarded by a rep (e.g. Gary) who named the actual customer in
|
||||
* the subject line. When set, `findOrCreateCustomer` resolves this
|
||||
* name against existing customers (case-insensitive substring match,
|
||||
* with token-drop fallback for "First Last Company" subjects) BEFORE
|
||||
* the `from_email` sender. If it resolves, the quote is linked to
|
||||
* the named person, not the rep. If it doesn't resolve, the sender
|
||||
* (rep) wins so the quote still lands attributable rather than on
|
||||
* the Unknown Sender sentinel.
|
||||
*/
|
||||
person_hint?: string;
|
||||
}
|
||||
|
||||
export interface IngestQuoteFromEmailResult {
|
||||
|
|
@ -151,11 +163,14 @@ export async function ingestQuoteFromEmail(
|
|||
}
|
||||
|
||||
// ── 2. Resolve or create the customer. Empty from_email → sentinel. ──
|
||||
// `person_hint` is forwarded to `findOrCreateCustomer` so the named
|
||||
// person wins over the rep's email when a rep (Gary) forwards an RFQ.
|
||||
const { customer } = fromEmail
|
||||
? await findOrCreateCustomer({
|
||||
email: fromEmail,
|
||||
name: payload.from_name,
|
||||
companyHint: payload.company_hint,
|
||||
personHint: payload.person_hint,
|
||||
})
|
||||
: await findOrCreateUnknownSenderCustomer(pb);
|
||||
|
||||
|
|
|
|||
|
|
@ -506,6 +506,85 @@ describe("findOrCreateCustomer", () => {
|
|||
expect(created).toBe(false);
|
||||
expect(customer.id).toBe(johnAcme.id);
|
||||
});
|
||||
|
||||
it("person_hint wins over the sender's existing email match", async () => {
|
||||
// The real-world bug this fixes: Gary (gary@wfsales.co.za) has his
|
||||
// own customer row from years of forwarding. Before the fix, the
|
||||
// email-exact-match check ran BEFORE person_hint, so every
|
||||
// Gary-forwarded RFQ landed on Gary's row even when the subject
|
||||
// named the actual customer. After the fix, person_hint resolves
|
||||
// first when it's set (the n8n workflow only sets it for forwarded
|
||||
// emails where the named person is the real customer), so the
|
||||
// quoted-for person wins.
|
||||
const gary = await pbMock.collection("customers").create({
|
||||
customer: "Gary",
|
||||
email: "gary@wfsales.co.za",
|
||||
company: "",
|
||||
});
|
||||
const john = await pbMock.collection("customers").create({
|
||||
customer: "John Smith",
|
||||
email: "john@personal.co.za",
|
||||
company: "",
|
||||
});
|
||||
const { customer, created } = await findOrCreateCustomer({
|
||||
email: "gary@wfsales.co.za",
|
||||
personHint: "John Smith",
|
||||
});
|
||||
expect(created).toBe(false);
|
||||
expect(customer.id).toBe(john.id);
|
||||
expect(customer.id).not.toBe(gary.id);
|
||||
});
|
||||
|
||||
it("token-drop: 3-token subject 'Jose Elia Redpath' finds 2-token customer 'Elia Jose'", async () => {
|
||||
// Gary's bare-subject format is "FirstName LastName Company" — the
|
||||
// pure-substring match on the whole string misses because the
|
||||
// customer table stores "LastName FirstName" (SA convention). The
|
||||
// token-drop fallback walks the prefix ladder (3 → 2 → 1 token)
|
||||
// and finds "Elia Jose" via the single-token rung ("Jose").
|
||||
const elia = await pbMock.collection("customers").create({
|
||||
customer: "Elia Jose",
|
||||
email: "Elia.Jose@redpathmining.com",
|
||||
company: "",
|
||||
});
|
||||
const { customer, created } = await findOrCreateCustomer({
|
||||
email: "gary@wfsales.co.za",
|
||||
personHint: "Jose Elia Redpath",
|
||||
});
|
||||
expect(created).toBe(false);
|
||||
expect(customer.id).toBe(elia.id);
|
||||
});
|
||||
|
||||
it("token-drop: single-token rung only fires for 2- or 3-token hints", async () => {
|
||||
// A 5-token sentence hint ("Please quote John Smith urgently")
|
||||
// must NOT degrade to matching every "John" in the database. The
|
||||
// single-token rung is gated to 2- and 3-token hints only, so
|
||||
// longer hints stay on their full prefix ladder and either match
|
||||
// on a multi-token rung or fall through.
|
||||
const john = await pbMock.collection("customers").create({
|
||||
customer: "John Smith",
|
||||
email: "john@personal.co.za",
|
||||
company: "",
|
||||
});
|
||||
await pbMock.collection("customers").create({
|
||||
customer: "Bob Johnson",
|
||||
email: "bob@bobcorp.co.za",
|
||||
company: "",
|
||||
});
|
||||
// "John Smith" should match via the 2-token rung (whole string is
|
||||
// already a substring of the customer name). The single-token
|
||||
// "John" rung would also match Bob Johnson? No — "John" in "Bob
|
||||
// Johnson" IS a substring. So we need a hint where neither rung
|
||||
// matches anything: "Xavier Quigley urgently please" → no match.
|
||||
// Then we expect fall-through to email → new Gary customer.
|
||||
const result = await findOrCreateCustomer({
|
||||
email: "gary-fresh@wfsales.co.za",
|
||||
personHint: "Xavier Quigley urgently please",
|
||||
});
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.customer.email).toBe("gary-fresh@wfsales.co.za");
|
||||
// sanity: the 2-token rung DOES still work for the simple case
|
||||
expect(john.id).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOrCreateCompany", () => {
|
||||
|
|
|
|||
|
|
@ -1006,11 +1006,13 @@ export async function findOrCreateCustomer(input: {
|
|||
* email is forwarded by a rep (e.g. Gary) who named the actual
|
||||
* customer in the subject line — the from_email then matches the rep,
|
||||
* not the customer, so `person_hint` lets us link the quote to the
|
||||
* right person. Resolution is case-insensitive exact match on the
|
||||
* `customer.customer` field; on a tie (multiple matches) we prefer
|
||||
* the one whose company matches `companyHint`. If no match, falls
|
||||
* through to the existing sender-based create-new path — Gary's
|
||||
* existing customer row wins, so the quote still lands attributable.
|
||||
* right person. Resolution is case-insensitive substring match on
|
||||
* the `customer.customer` field, with a token-drop fallback so a
|
||||
* 3-token subject like "Jose Elia Redpath" still finds the 2-token
|
||||
* customer "Elia Jose". On a tie (multiple matches) we prefer the
|
||||
* one whose company matches `companyHint`. If no match, falls
|
||||
* through to the sender's email — the rep's existing customer row
|
||||
* wins, so the quote still lands attributable.
|
||||
*/
|
||||
personHint?: string;
|
||||
}): Promise<{ customer: Customer; created: boolean }> {
|
||||
|
|
@ -1020,16 +1022,12 @@ export async function findOrCreateCustomer(input: {
|
|||
throw new Error("Customer email is required for findOrCreateCustomer.");
|
||||
}
|
||||
|
||||
const exact = await findCustomerByExactEmail(pb, email);
|
||||
if (exact) {
|
||||
return { customer: exact, created: false };
|
||||
}
|
||||
|
||||
// Rep-forwarded case: the sender (e.g. Gary) is not the customer
|
||||
// being quoted for. Try to resolve the named person against the
|
||||
// customer table before falling back to creating a new customer row
|
||||
// tied to the sender's email — otherwise we'd auto-create a junk
|
||||
// "Gary" customer or duplicate him across every forwarded quote.
|
||||
// Rep-forwarded case runs FIRST when a person_hint is supplied. The
|
||||
// n8n workflow only sets `person_hint` when it has decided the email
|
||||
// is forwarded and the named person is the real customer — so when
|
||||
// the hint resolves, the named person is always preferred over the
|
||||
// rep's email. Without this, every Gary-forwarded RFQ would land on
|
||||
// Gary's existing customer row.
|
||||
const personHint = (input.personHint || "").trim();
|
||||
if (personHint) {
|
||||
const personMatch = await findCustomerByNameHint(pb, personHint, input.companyHint);
|
||||
|
|
@ -1038,6 +1036,11 @@ export async function findOrCreateCustomer(input: {
|
|||
}
|
||||
}
|
||||
|
||||
const exact = await findCustomerByExactEmail(pb, email);
|
||||
if (exact) {
|
||||
return { customer: exact, created: false };
|
||||
}
|
||||
|
||||
const senderDomain = email.split("@")[1] || "";
|
||||
const fallbackName = input.name?.trim() || senderDomain || email;
|
||||
// Resolve the company hint to a relation id, not a free-text name —
|
||||
|
|
@ -1139,10 +1142,18 @@ async function findCustomerByExactEmail(
|
|||
|
||||
/**
|
||||
* Resolve a free-text person name (e.g. "John Smith" extracted from the
|
||||
* email subject) to an existing customer row. Two-step match:
|
||||
* email subject) to an existing customer row. Two-step match with a
|
||||
* token-drop fallback for multi-token subjects:
|
||||
*
|
||||
* 1. Case-insensitive substring match on `customer.customer` via PB's
|
||||
* `~` operator. Returns up to N candidates — usually one.
|
||||
* `~` operator. Tries the longest prefix first ("Jose Elia
|
||||
* Redpath"), then drops one trailing token at a time ("Jose
|
||||
* Elia", then "Jose") until either a match is found or only one
|
||||
* token remains. This catches the common SA rep-forwarded
|
||||
* pattern where the subject is "FirstName LastName CompanyName"
|
||||
* and the customer DB has "LastName FirstName" — pure substring
|
||||
* on the 3-token string misses but the 1-token suffix still
|
||||
* hits.
|
||||
* 2. If multiple candidates come back (two "John Smith"s in the
|
||||
* database), prefer the one whose company relation matches
|
||||
* `companyHint` (case-insensitive exact on `company.company`).
|
||||
|
|
@ -1150,9 +1161,9 @@ async function findCustomerByExactEmail(
|
|||
* or no company match was found.
|
||||
*
|
||||
* Returns `null` when no candidate matches. The caller falls through
|
||||
* to the sender-based create-new path in that case — the rep (Gary)
|
||||
* becomes the customer, which is at least attributable and the
|
||||
* quoter can reassign in the WFDASH UI.
|
||||
* to the sender's email — the rep (Gary) becomes the customer, which
|
||||
* is at least attributable and the quoter can reassign in the WFDASH
|
||||
* UI.
|
||||
*/
|
||||
async function findCustomerByNameHint(
|
||||
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||||
|
|
@ -1161,40 +1172,57 @@ async function findCustomerByNameHint(
|
|||
): Promise<Customer | null> {
|
||||
const trimmed = personHint.trim();
|
||||
if (!trimmed) return null;
|
||||
const esc = trimmed.replace(/"/g, '\\"');
|
||||
|
||||
// PB `~` is case-insensitive substring. We grab a small page; in
|
||||
// practice this returns 1-3 rows for any non-trivial name.
|
||||
let candidates: Customer[] = [];
|
||||
try {
|
||||
candidates = (
|
||||
await pb.collection(CUSTOMERS_COLLECTION).getList<Customer>(1, 5, {
|
||||
filter: `customer ~ "${esc}"`,
|
||||
fields: "id,customer,company",
|
||||
expand: "company",
|
||||
})
|
||||
).items;
|
||||
} catch {
|
||||
return null;
|
||||
// Build the prefix ladder: longest, longest-1, longest-2, ... down to
|
||||
// one token. We skip the single-token rung for hints longer than 3
|
||||
// tokens to avoid wildly-over-broad substring matches (e.g.
|
||||
// "Mary" matching every Maria / Marie / Mariana in the book).
|
||||
const tokens = trimmed.split(/\s+/).filter(Boolean);
|
||||
const rungs: string[] = [];
|
||||
for (let len = tokens.length; len >= 2; len--) {
|
||||
rungs.push(tokens.slice(0, len).join(" "));
|
||||
}
|
||||
// Single-token rung only for 2- and 3-token hints — covers the
|
||||
// common "Jose Elia Redpath" → "Jose" → "Elia Jose" case without
|
||||
// over-matching on full sentences.
|
||||
if (tokens.length >= 2 && tokens.length <= 3) {
|
||||
rungs.push(tokens[0]);
|
||||
}
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
// Tie-break by company hint when available. The AI usually passes
|
||||
// both, so the hint effectively narrows the candidate set.
|
||||
const hint = (companyHint || "").trim();
|
||||
if (hint) {
|
||||
const hintEsc = hint.replace(/"/g, '\\"');
|
||||
for (const c of candidates) {
|
||||
const companyName = (c.expand?.company?.company || "") as string;
|
||||
if (companyName && companyName.toLowerCase() === hintEsc.toLowerCase()) {
|
||||
return c;
|
||||
for (const rung of rungs) {
|
||||
const esc = rung.replace(/"/g, '\\"');
|
||||
let candidates: Customer[] = [];
|
||||
try {
|
||||
candidates = (
|
||||
await pb.collection(CUSTOMERS_COLLECTION).getList<Customer>(1, 5, {
|
||||
filter: `customer ~ "${esc}"`,
|
||||
fields: "id,customer,company",
|
||||
expand: "company",
|
||||
})
|
||||
).items;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (candidates.length === 0) continue;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
// Tie-break by company hint when available. The AI usually passes
|
||||
// both, so the hint effectively narrows the candidate set.
|
||||
const hint = (companyHint || "").trim();
|
||||
if (hint) {
|
||||
const hintEsc = hint.replace(/"/g, '\\"');
|
||||
for (const c of candidates) {
|
||||
const companyName = (c.expand?.company?.company || "") as string;
|
||||
if (companyName && companyName.toLowerCase() === hintEsc.toLowerCase()) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No company hint or no company match — return the first candidate
|
||||
// rather than creating a duplicate. Quoter can disambiguate.
|
||||
return candidates[0];
|
||||
}
|
||||
// No company hint or no company match — return the first candidate
|
||||
// rather than creating a duplicate. Quoter can disambiguate.
|
||||
return candidates[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in a new issue