WFDASH-WEBAPP/n8n_workfows/WFDASH — Price List Match.js
SJCoetz 05abe499b0 feat(sprint): add new sprint pricing workflow and supporting code
Add full sprint pricing workflow and supporting infrastructure:
- New sidebar navigation entry for the sprint dashboard
- PocketBase category collection schema and migration script
- Server API routes for sprint item claims, attachments, counts, and file proxying
- Dev identity utilities and UI switcher for multi-user testing
- Reusable FilterTabs component replacing ItemStatusTabs
- Updated TypeScript types for sprint-related entities

Also include:
- Fix PocketBase file URL method casing (getUrl → getURL) across codebase
- Add development utility, smoke test, and helper scripts
- Update package.json with new sprint migration command
2026-07-03 07:29:43 +02:00

260 lines
No EOL
9 KiB
JavaScript

/**
* WFDASH — Price List Match (v2a)
*
* Webhook-driven catalogue matcher. Called by the dashboard's
* `/api/v1/quotes/[id]/items/[itemId]/match` route when the operator
* hits the new "Help me find this" button on the process page.
*
* Inputs (POST body):
* - sku: string (the buyer's SKU or partial code)
* - description: string (free-text description from the operator)
* - pbUrl: string (PocketBase base URL — passed in by Next.js
* so the workflow stays portable across environments)
* - pbToken: string (PocketBase admin JWT — Next.js refreshes
* on every call)
* - limit: number (default 4 — top-N to return)
*
* Behaviour:
* - Build a single combined PB filter that covers Tier 1 (exact SKU),
* Tier 2 (SKU substring), AND Tier 3 (search_text fuzzy). Run it
* as one HTTP query. Downstream, classify each row by what it
* actually matched (Tier 1 > 2 > 3), sort, cap at `limit`.
*
* - Combining tiers into one query is safe because `classifyMatch`
* inspects the row, not the query — it correctly identifies which
* tier each individual row matches. Single-query beats three-query
* (faster, one round-trip, no item multiplication risk).
*
* Output:
* - { candidates: Candidate[] } (Candidate = same shape the
* dashboard's Picker modal already consumes)
*
* v2b swap-in: add an LLM agent node between HTTP Request and Rank +
* Cap that re-ranks the raw candidates semantically (e.g. "turbo"
* matching turbine-adjacent items even without an exact token hit).
* The webhook contract stays identical.
*/
import { workflow, trigger, node } from '@n8n/workflow-sdk';
const matchWebhook = trigger({
type: 'n8n-nodes-base.webhook',
version: 2,
config: {
name: 'Match Webhook',
parameters: {
httpMethod: 'POST',
path: 'wf-price-list-match',
responseMode: 'lastNode',
options: {},
},
},
});
const buildFilter = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Build Filter',
parameters: {
jsCode: `// Parse webhook payload + build a single combined PB filter that
// covers Tier 1 + Tier 2 + Tier 3. Downstream classifies each row.
//
// Always output data (even on error / no-input) so the HTTP Request
// and Rank+Cap nodes run regardless — they handle the empty case.
const src = ($json.body && typeof $json.body === 'object') ? $json.body : $json;
const sku = String(src.sku || '').trim();
const description = String(src.description || '').trim();
const pbUrl = String(src.pbUrl || '').replace(/\\/$/, '');
const pbToken = String(src.pbToken || '');
const limit = Math.max(1, Math.min(20, Number(src.limit) || 4));
function escapeForPbString(value) {
return String(value || '').replace(/"/g, '\\\\"');
}
function normaliseSku(raw) {
if (!raw) return '';
return raw.toUpperCase().replace(/[^A-Z0-9]/g, '');
}
// Build the URL. When there's no usable input OR no PB creds, point
// at a known-empty PB filter so downstream nodes still execute.
let url;
if (!pbUrl) {
url = 'about:blank';
} else if (!sku && !description) {
// No input — point at an always-empty filter so the query returns
// 0 items. PB accepts \`filter=id="__none__"\` cleanly.
url = pbUrl + '/api/collections/price_list/records?page=1&perPage=1&filter=' + encodeURIComponent('id="__none__"');
} else {
const normalised = normaliseSku(sku);
const parts = [];
if (sku) {
// Tier 1 — exact equality on raw + canonical form.
parts.push('sku = "' + escapeForPbString(sku) + '"');
parts.push('sku_normalised = "' + escapeForPbString(normalised) + '"');
// Tier 2 — substring on canonical + raw form.
parts.push('sku_normalised ~ "' + escapeForPbString(normalised) + '"');
parts.push('sku ~ "' + escapeForPbString(sku) + '"');
}
// Tier 3 — fuzzy search_text (sku + description combined so we hit
// both brand-only and description-only entries).
const searchQuery = (sku + ' ' + description).trim();
if (searchQuery) {
parts.push('search_text ~ "' + escapeForPbString(searchQuery) + '"');
}
const filter = '(' + parts.join(' || ') + ')';
const fields = 'id,sku,description,cost_price,selling_price,unit,supplier,brand,category,subcategory,sku_normalised,updated,specifications,search_text';
url = pbUrl + '/api/collections/price_list/records?page=1&perPage=30'
+ '&fields=' + encodeURIComponent(fields)
+ '&filter=' + encodeURIComponent(filter);
}
return [{ json: { url, pbToken, sku, description, limit } }];`,
},
},
});
const queryPb = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.4,
config: {
name: 'Query PB',
parameters: {
method: 'GET',
url: '={{ $json.url }}',
sendHeaders: true,
headerParameters: {
parameters: [
{
name: 'Authorization',
value: '={{ $json.pbToken }}',
},
],
},
options: {
timeout: 10000,
// Continue even on non-2xx so Rank+Cap can return a friendly
// empty response instead of throwing.
response: {
response: {
neverError: true,
},
},
},
},
},
});
const rankAndCap = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Rank and Cap',
parameters: {
jsCode: `// Read HTTP response + meta from Build Filter. Classify each row's
// tier (independently — a single combined query returned everything),
// generate the rule-based "why" line, sort by tier, cap at limit.
//
// On any failure here we still return \`{ candidates: [] }\` so the
// dashboard always gets a parseable response shape.
function normaliseSku(raw) {
if (!raw) return '';
return raw.toUpperCase().replace(/[^A-Z0-9]/g, '');
}
function classifyMatch(rawInput, match) {
const raw = (rawInput || '').trim();
const norm = normaliseSku(raw);
if (match.sku === raw || (norm && match.sku_normalised === norm)) return 1;
if ((norm && (match.sku_normalised || '').includes(norm)) || (raw && (match.sku || '').includes(raw))) return 2;
return 3;
}
function explainMatch(rawInput, match, tier) {
const raw = (rawInput || '').trim();
const inputLower = raw.toLowerCase();
const inputWords = inputLower.split(/\\s+/).filter((w) => w.length >= 2);
const bits = [match.sku || '(no sku)'];
if (tier === 1) bits.push('exact SKU match');
else if (tier === 2) bits.push('SKU substring');
if (tier === 3) {
const detailBits = [];
if (match.brand && inputWords.some((w) => match.brand.toLowerCase().includes(w))) {
detailBits.push('brand: ' + match.brand);
}
if (match.category && inputWords.some((w) => match.category.toLowerCase().includes(w))) {
detailBits.push('category: ' + match.category);
}
if (match.supplier && inputWords.some((w) => match.supplier.toLowerCase().includes(w))) {
detailBits.push('supplier: ' + match.supplier);
}
if (match.description) {
const descLower = match.description.toLowerCase();
const matchedWords = inputWords.filter((w) => descLower.includes(w));
if (matchedWords.length > 0) {
detailBits.push('description matches "' + matchedWords.slice(0, 3).join(', ') + '"');
}
}
if (match.specifications && typeof match.specifications === 'object') {
for (const [k, v] of Object.entries(match.specifications)) {
if (typeof v === 'string' && inputWords.some((w) => v.toLowerCase().includes(w))) {
detailBits.push(k + ': ' + v);
break;
}
}
}
if (detailBits.length === 0) detailBits.push('"' + raw + '" found in catalogue text');
bits.push(detailBits.join('; '));
}
if (match.supplier) bits.push('from ' + match.supplier);
return bits.slice(0, 4).join(' \\u2014 ').slice(0, 240);
}
try {
const meta = ($('Build Filter').first().json) || {};
const sku = String(meta.sku || '');
const description = String(meta.description || '');
const limit = Math.max(1, Math.min(20, Number(meta.limit) || 4));
const classifyKey = sku || description;
const items = (Array.isArray($json.items) ? $json.items : []);
const enriched = items.map((c) => {
const tier = classifyMatch(classifyKey, c);
return {
id: c.id,
sku: c.sku || '',
description: c.description || '',
brand: c.brand || '',
category: c.category || '',
supplier: c.supplier || '',
cost_price: Number(c.cost_price) || 0,
selling_price: Number(c.selling_price) || 0,
unit: c.unit || '',
tier,
why: explainMatch(classifyKey, c, tier),
};
});
const tierRank = (t) => (t === 1 ? 0 : t === 2 ? 1 : 2);
enriched.sort((a, b) => tierRank(a.tier) - tierRank(b.tier));
const top = enriched.slice(0, limit);
return [{ json: { candidates: top, query: { sku, description } } }];
} catch (err) {
return [{ json: { candidates: [], error: err && err.message ? err.message : String(err) } }];
}
`,
},
},
});
export default workflow('wf-price-list-match', 'WFDASH — Price List Match')
.add(matchWebhook)
.to(buildFilter)
.to(queryPb)
.to(rankAndCap);