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 '';
};
// 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:
// 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
const matchProduct = (
products: IProduct[],
transaction: any
): IProduct | undefined => {
const variantStr = (transaction.variations || [])
.map((v: any) => `${v.formatted_name}: ${v.formatted_value}`)
.join(', ');
const variantStr = transactionVariantString(transaction);
const candidatesByTitle = [
transaction.title || '',
variantStr ? `${transaction.title} ${variantStr}` : '',
transaction.title || '',
].filter(Boolean).map(normalizeTitle);
for (const product of products) {
const names = [product.title, ...(product.aliases || [])].map(normalizeTitle);
if (candidatesByTitle.some(c => names.includes(c))) return product;
for (const candidate of candidatesByTitle) {
const product = products.find(p =>
[p.title, ...(p.aliases || [])].map(normalizeTitle).includes(candidate)
);
if (product) return product;
}
if (transaction.listing_id) {
@ -243,11 +250,15 @@ const upsertOrderFromReceipt = async (
// Snapshot catalog costs onto items at sync time (same model as PDF import)
let allMatched = true;
const items = (receipt.transactions || []).map((transaction: any) => {
const variantStr = transactionVariantString(transaction);
const product = matchProduct(products, transaction);
if (!product) {
allMatched = false;
if (!result.unmatchedItems.includes(transaction.title)) {
result.unmatchedItems.push(transaction.title);
// Report with the variant suffix so each size resolves to its own
// 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) {
// Learn the listing id for future deterministic matching
@ -255,10 +266,6 @@ const upsertOrderFromReceipt = async (
product.save().catch(() => {});
}
const variantStr = (transaction.variations || [])
.map((v: any) => `${v.formatted_name}: ${v.formatted_value}`)
.join(', ');
return {
title: transaction.title,
quantity: transaction.quantity || 1,