Make Etsy sync matching and unmatched reporting variant-aware

One listing can sell multiple sizes that map to different catalog
products with different costs. Match 'title + variant' before bare
title so size-specific products win, and report unmatched items with
their variant suffix so each size resolves to its own product.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-06-13 11:41:06 +10:00
parent acce14c000
commit 9219f01ae5

View file

@ -151,24 +151,31 @@ const sizeToken = (text: string): string => {
return ''; return '';
}; };
// Build the variant suffix for a transaction, e.g. "Size: Large"
export const transactionVariantString = (transaction: any): string =>
(transaction.variations || [])
.map((v: any) => `${v.formatted_name}: ${v.formatted_value}`)
.join(', ');
// Match an Etsy transaction to a catalog product: // Match an Etsy transaction to a catalog product:
// 1. exact title/alias match (including "title variations" form) // 1. exact title/alias match — "title variant" first so a size-specific
// product beats a generic one when listings have size variants
// 2. listing id match, disambiguated by size when several products share it // 2. listing id match, disambiguated by size when several products share it
const matchProduct = ( const matchProduct = (
products: IProduct[], products: IProduct[],
transaction: any transaction: any
): IProduct | undefined => { ): IProduct | undefined => {
const variantStr = (transaction.variations || []) const variantStr = transactionVariantString(transaction);
.map((v: any) => `${v.formatted_name}: ${v.formatted_value}`)
.join(', ');
const candidatesByTitle = [ const candidatesByTitle = [
transaction.title || '',
variantStr ? `${transaction.title} ${variantStr}` : '', variantStr ? `${transaction.title} ${variantStr}` : '',
transaction.title || '',
].filter(Boolean).map(normalizeTitle); ].filter(Boolean).map(normalizeTitle);
for (const product of products) { for (const candidate of candidatesByTitle) {
const names = [product.title, ...(product.aliases || [])].map(normalizeTitle); const product = products.find(p =>
if (candidatesByTitle.some(c => names.includes(c))) return product; [p.title, ...(p.aliases || [])].map(normalizeTitle).includes(candidate)
);
if (product) return product;
} }
if (transaction.listing_id) { if (transaction.listing_id) {
@ -243,11 +250,15 @@ const upsertOrderFromReceipt = async (
// Snapshot catalog costs onto items at sync time (same model as PDF import) // Snapshot catalog costs onto items at sync time (same model as PDF import)
let allMatched = true; let allMatched = true;
const items = (receipt.transactions || []).map((transaction: any) => { const items = (receipt.transactions || []).map((transaction: any) => {
const variantStr = transactionVariantString(transaction);
const product = matchProduct(products, transaction); const product = matchProduct(products, transaction);
if (!product) { if (!product) {
allMatched = false; allMatched = false;
if (!result.unmatchedItems.includes(transaction.title)) { // Report with the variant suffix so each size resolves to its own
result.unmatchedItems.push(transaction.title); // product; this exact string is what the matcher checks on re-sync
const unmatchedKey = variantStr ? `${transaction.title} ${variantStr}` : transaction.title;
if (!result.unmatchedItems.includes(unmatchedKey)) {
result.unmatchedItems.push(unmatchedKey);
} }
} else if (!product.etsyListingId && transaction.listing_id) { } else if (!product.etsyListingId && transaction.listing_id) {
// Learn the listing id for future deterministic matching // Learn the listing id for future deterministic matching
@ -255,10 +266,6 @@ const upsertOrderFromReceipt = async (
product.save().catch(() => {}); product.save().catch(() => {});
} }
const variantStr = (transaction.variations || [])
.map((v: any) => `${v.formatted_name}: ${v.formatted_value}`)
.join(', ');
return { return {
title: transaction.title, title: transaction.title,
quantity: transaction.quantity || 1, quantity: transaction.quantity || 1,