Phase 3: sync active Etsy listings into the product catalog
- syncListings pages active listings, fetches each listing's inventory, and upserts Products (one per size variation, collapsing colour). Title/description/price/tags/SKU/quantity and etsyListingId are updated; printingCost/costOfGoods are preserved (user-owned). Old titles are kept as aliases so historical order matching still works. - POST /api/etsy/sync-catalog endpoint; Settings gains a 'Sync Catalog' button that refreshes products after running. - Uses existing listings_r scope (no reconnect needed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a470707fae
commit
2b15fd505e
3 changed files with 234 additions and 1 deletions
|
|
@ -2,8 +2,9 @@ import { useState, useEffect } from 'react';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { DataManager } from '../utils/dataManager';
|
import { DataManager } from '../utils/dataManager';
|
||||||
import { setOrders } from '../store/slices/orderSlice';
|
import { setOrders } from '../store/slices/orderSlice';
|
||||||
|
import { setProducts } from '../store/slices/productSlice';
|
||||||
import { MissingProductsModal } from '../components/MissingProductsModal';
|
import { MissingProductsModal } from '../components/MissingProductsModal';
|
||||||
import { Trash2, Download, RefreshCw, AlertTriangle, Database, Store, Link2, Unlink, Wrench } from 'lucide-react';
|
import { Trash2, Download, RefreshCw, AlertTriangle, Database, Store, Link2, Unlink, Wrench, Package } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import api from '../utils/api';
|
import api from '../utils/api';
|
||||||
|
|
||||||
|
|
@ -40,6 +41,7 @@ const Settings = () => {
|
||||||
const [isSavingConfig, setIsSavingConfig] = useState(false);
|
const [isSavingConfig, setIsSavingConfig] = useState(false);
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
|
const [isSyncingCatalog, setIsSyncingCatalog] = useState(false);
|
||||||
const [unmatchedItems, setUnmatchedItems] = useState<string[]>([]);
|
const [unmatchedItems, setUnmatchedItems] = useState<string[]>([]);
|
||||||
const [showResolveModal, setShowResolveModal] = useState(false);
|
const [showResolveModal, setShowResolveModal] = useState(false);
|
||||||
const [unknownDebits, setUnknownDebits] = useState<string[]>([]);
|
const [unknownDebits, setUnknownDebits] = useState<string[]>([]);
|
||||||
|
|
@ -165,6 +167,21 @@ const Settings = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSyncCatalog = async () => {
|
||||||
|
setIsSyncingCatalog(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post('/etsy/sync-catalog');
|
||||||
|
const { listingsSeen, productsCreated, productsUpdated } = res.data;
|
||||||
|
toast.success(`Catalog synced: ${listingsSeen} listings → ${productsCreated} new, ${productsUpdated} updated products`);
|
||||||
|
const productsRes = await api.get('/products?limit=1000');
|
||||||
|
dispatch(setProducts(productsRes.data.products));
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || 'Catalog sync failed');
|
||||||
|
} finally {
|
||||||
|
setIsSyncingCatalog(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleEtsyDisconnect = async () => {
|
const handleEtsyDisconnect = async () => {
|
||||||
if (!window.confirm('Disconnect your Etsy shop? Synced data stays; only the connection is removed.')) return;
|
if (!window.confirm('Disconnect your Etsy shop? Synced data stays; only the connection is removed.')) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -245,6 +262,15 @@ const Settings = () => {
|
||||||
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
|
||||||
{isSyncing ? 'Syncing...' : 'Sync Orders Now'}
|
{isSyncing ? 'Syncing...' : 'Sync Orders Now'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSyncCatalog}
|
||||||
|
disabled={isSyncingCatalog}
|
||||||
|
title="Import active listings into your product catalog (one product per size; costs are kept)"
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-orange-100 text-orange-700 rounded-lg hover:bg-orange-200 disabled:bg-gray-200"
|
||||||
|
>
|
||||||
|
<Package className={`w-4 h-4 ${isSyncingCatalog ? 'animate-spin' : ''}`} />
|
||||||
|
{isSyncingCatalog ? 'Syncing...' : 'Sync Catalog'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleEtsyDisconnect}
|
onClick={handleEtsyDisconnect}
|
||||||
className="flex items-center gap-2 px-4 py-2 text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50"
|
className="flex items-center gap-2 px-4 py-2 text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
syncReceipts,
|
syncReceipts,
|
||||||
syncLedgerEntries,
|
syncLedgerEntries,
|
||||||
dedupeLedgerExpenses,
|
dedupeLedgerExpenses,
|
||||||
|
syncListings,
|
||||||
} from '../services/etsyApi';
|
} from '../services/etsyApi';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
@ -238,6 +239,24 @@ router.post('/sync', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync the active Etsy listings into the product catalog (one product per
|
||||||
|
// size variation). Updates everything except costs, which stay user-owned.
|
||||||
|
router.post('/sync-catalog', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const connection = await EtsyConnection.findOne({ userId: req.userId });
|
||||||
|
if (!connection) return res.status(400).json({ message: 'Etsy is not connected' });
|
||||||
|
|
||||||
|
const config = await loadConfig(req.userId!);
|
||||||
|
if (!config) return res.status(400).json({ message: 'Etsy API key is not configured' });
|
||||||
|
|
||||||
|
const result = await syncListings(toCredentials(config), connection);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Etsy catalog sync failed:', err);
|
||||||
|
res.status(500).json({ message: err.message || 'Etsy catalog sync failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Remove fee expenses imported from the statement CSV before ledger sync,
|
// Remove fee expenses imported from the statement CSV before ledger sync,
|
||||||
// so the ledger becomes the single source of Etsy fees (no double counting)
|
// so the ledger becomes the single source of Etsy fees (no double counting)
|
||||||
router.delete('/legacy-fees', authenticate, async (req: AuthRequest, res: Response) => {
|
router.delete('/legacy-fees', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
|
|
||||||
|
|
@ -494,3 +494,191 @@ export const dedupeLedgerExpenses = async (userId: any): Promise<number> => {
|
||||||
const res = await Expense.deleteMany({ _id: { $in: toDelete }, userId });
|
const res = await Expense.deleteMany({ _id: { $in: toDelete }, userId });
|
||||||
return res.deletedCount || 0;
|
return res.deletedCount || 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------- Active listings → Product catalog sync ----------
|
||||||
|
|
||||||
|
export interface CatalogSyncResult {
|
||||||
|
listingsSeen: number;
|
||||||
|
productsCreated: number;
|
||||||
|
productsUpdated: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normTitle = (t: string): string => t.replace(/\s+/g, ' ').trim().toLowerCase();
|
||||||
|
|
||||||
|
// Size token from a title ("... Size: Large" → "large"), with large/small fallback
|
||||||
|
const titleSize = (text: string): string => {
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
const m = lower.match(/size:\s*([a-z0-9]+)/);
|
||||||
|
if (m) return m[1];
|
||||||
|
if (lower.includes('large') && !lower.includes('small')) return 'large';
|
||||||
|
if (lower.includes('small') && !lower.includes('large')) return 'small';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// The size value of an inventory product, from its "Size" variation property
|
||||||
|
const inventorySize = (invProduct: any): string => {
|
||||||
|
const sizeProp = (invProduct.property_values || []).find((pv: any) =>
|
||||||
|
/size/i.test(pv.property_name || '')
|
||||||
|
);
|
||||||
|
return sizeProp?.values?.[0] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ListingProductData {
|
||||||
|
listingId: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
price: number;
|
||||||
|
quantity: number;
|
||||||
|
sku?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertCatalogProduct = async (
|
||||||
|
connection: IEtsyConnection,
|
||||||
|
data: ListingProductData,
|
||||||
|
result: CatalogSyncResult
|
||||||
|
): Promise<void> => {
|
||||||
|
const candidates = await Product.find({
|
||||||
|
userId: connection.userId,
|
||||||
|
$or: [
|
||||||
|
{ etsyListingId: data.listingId },
|
||||||
|
{ title: data.title },
|
||||||
|
{ aliases: data.title },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const wantedSize = titleSize(data.title);
|
||||||
|
const existing =
|
||||||
|
candidates.find(p => p.etsyListingId === data.listingId && titleSize(p.title) === wantedSize) ||
|
||||||
|
candidates.find(p =>
|
||||||
|
normTitle(p.title) === normTitle(data.title) ||
|
||||||
|
(p.aliases || []).some((a: string) => normTitle(a) === normTitle(data.title))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update everything except costs; keep old title as an alias so historical
|
||||||
|
// order matching by that title still resolves
|
||||||
|
const aliases = new Set(existing.aliases || []);
|
||||||
|
if (normTitle(existing.title) !== normTitle(data.title)) aliases.add(existing.title);
|
||||||
|
|
||||||
|
await Product.findByIdAndUpdate(existing._id, {
|
||||||
|
$set: {
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
price: data.price,
|
||||||
|
tags: data.tags,
|
||||||
|
sku: data.sku || existing.sku || '',
|
||||||
|
etsyListingId: data.listingId,
|
||||||
|
aliases: Array.from(aliases),
|
||||||
|
'inventory.quantity': data.quantity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
result.productsUpdated++;
|
||||||
|
} else {
|
||||||
|
await Product.create({
|
||||||
|
userId: connection.userId,
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
price: data.price,
|
||||||
|
tags: data.tags,
|
||||||
|
sku: data.sku || '',
|
||||||
|
category: 'Other',
|
||||||
|
etsyListingId: data.listingId,
|
||||||
|
aliases: [],
|
||||||
|
costOfGoods: 0,
|
||||||
|
printingCost: 0,
|
||||||
|
inventory: { quantity: data.quantity, lowStockAlert: 5 },
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
result.productsCreated++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertProductsFromListing = async (
|
||||||
|
creds: EtsyCredentials,
|
||||||
|
connection: IEtsyConnection,
|
||||||
|
listing: any,
|
||||||
|
result: CatalogSyncResult
|
||||||
|
): Promise<void> => {
|
||||||
|
const listingId = String(listing.listing_id);
|
||||||
|
const baseTitle = listing.title || `Etsy listing ${listingId}`;
|
||||||
|
const description = listing.description || '';
|
||||||
|
const tags = listing.tags || [];
|
||||||
|
const listingPrice = money(listing.price);
|
||||||
|
|
||||||
|
let inventory: any = null;
|
||||||
|
try {
|
||||||
|
inventory = await etsyGet(creds, connection, `/application/listings/${listingId}/inventory`);
|
||||||
|
} catch {
|
||||||
|
// Listing may have no inventory record; treated as a single product below
|
||||||
|
}
|
||||||
|
|
||||||
|
const invProducts = (inventory?.products || []).filter((p: any) => !p.is_deleted);
|
||||||
|
|
||||||
|
// Group inventory by size (collapsing colour and other variations)
|
||||||
|
const groups = new Map<string, { size: string; quantity: number; price: number; sku?: string }>();
|
||||||
|
if (invProducts.length > 0) {
|
||||||
|
for (const ip of invProducts) {
|
||||||
|
const size = inventorySize(ip);
|
||||||
|
const key = size.toLowerCase();
|
||||||
|
const offerings = (ip.offerings || []).filter((o: any) => !o.is_deleted);
|
||||||
|
const qty = offerings.reduce((s: number, o: any) => s + (o.quantity || 0), 0);
|
||||||
|
const price = offerings.length ? money(offerings[0].price) : listingPrice;
|
||||||
|
const g = groups.get(key);
|
||||||
|
if (g) {
|
||||||
|
g.quantity += qty;
|
||||||
|
if (!g.sku && ip.sku) g.sku = ip.sku;
|
||||||
|
} else {
|
||||||
|
groups.set(key, { size, quantity: qty, price: price || listingPrice, sku: ip.sku || undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
groups.set('', { size: '', quantity: listing.quantity || 0, price: listingPrice });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const g of groups.values()) {
|
||||||
|
await upsertCatalogProduct(
|
||||||
|
connection,
|
||||||
|
{
|
||||||
|
listingId,
|
||||||
|
title: g.size ? `${baseTitle} Size: ${g.size}` : baseTitle,
|
||||||
|
description,
|
||||||
|
tags,
|
||||||
|
price: g.price,
|
||||||
|
quantity: g.quantity,
|
||||||
|
sku: g.sku,
|
||||||
|
},
|
||||||
|
result
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syncListings = async (
|
||||||
|
creds: EtsyCredentials,
|
||||||
|
connection: IEtsyConnection
|
||||||
|
): Promise<CatalogSyncResult> => {
|
||||||
|
const result: CatalogSyncResult = { listingsSeen: 0, productsCreated: 0, productsUpdated: 0 };
|
||||||
|
|
||||||
|
const limit = 100;
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const page = await etsyGet(
|
||||||
|
creds,
|
||||||
|
connection,
|
||||||
|
`/application/shops/${connection.shopId}/listings/active?limit=${limit}&offset=${offset}`
|
||||||
|
);
|
||||||
|
const listings: any[] = page.results || [];
|
||||||
|
if (listings.length === 0) break;
|
||||||
|
|
||||||
|
for (const listing of listings) {
|
||||||
|
result.listingsSeen++;
|
||||||
|
await upsertProductsFromListing(creds, connection, listing, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += listings.length;
|
||||||
|
if (offset >= (page.count || 0)) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue