diff --git a/packages/scraper/src/robots/flexoptix-api-sync.ts b/packages/scraper/src/robots/flexoptix-api-sync.ts index f180dcb..4ca7a2a 100644 --- a/packages/scraper/src/robots/flexoptix-api-sync.ts +++ b/packages/scraper/src/robots/flexoptix-api-sync.ts @@ -40,8 +40,11 @@ interface CatalogProduct { title: string; url: string | null; imageUrl: string | null; + productType: string | null; price: { amount: number | null; + listAmount: number | null; + selfConfigureAmount: number | null; currency: string | null; source: "api" | "missing"; fetchedAt: string; @@ -255,7 +258,10 @@ function normalizeProduct(row: JsonRecord, fetchedAt: string): CatalogProduct | const url = asString(pick(flat, ["url", "productUrl", "canonicalUrl", "link"])); const imageUrl = asString(pick(flat, ["image", "imageUrl", "productImage", "thumbnail"])); + const productType = asString(pick(flat, ["type", "producttype", "moduletype"])); const amount = asNumber(pick(flat, ["price", "priceNet", "netPrice", "grossPrice", "amount"])); + const listAmount = asNumber(pick(flat, ["listprice", "msrp", "rrp", "recommendedprice"])); + const selfConfigureAmount = asNumber(pick(flat, ["selfconfigureprice", "selfconfigprice"])); const currency = asString(pick(flat, ["currency", "priceCurrency", "currencyCode"])) ?? (amount === null ? null : process.env["FLEXOPTIX_API_CURRENCY"]?.trim() ?? "EUR"); const quantity = asNumber(pick(flat, ["stock", "stockQuantity", "quantity", "availableQuantity"])); @@ -277,10 +283,13 @@ function normalizeProduct(row: JsonRecord, fetchedAt: string): CatalogProduct | fetchedAt, sku, title, + productType, url, imageUrl, price: { amount, + listAmount: (listAmount !== null && listAmount !== amount) ? listAmount : null, + selfConfigureAmount, currency, source: amount === null ? "missing" : "api", fetchedAt, @@ -332,6 +341,8 @@ function speedLabel(speedGbps: number | null): string | undefined { } function categoryFor(product: CatalogProduct): string { + const apiType = product.productType?.toLowerCase() ?? ""; + if (apiType.includes("cable")) return "Cable"; const text = `${product.title} ${product.optics.protocol ?? ""}`.toLowerCase(); if (/\bdac\b|direct attach|copper/.test(text)) return "DAC"; if (/\baoc\b|active optical/.test(text)) return "AOC"; @@ -389,6 +400,48 @@ async function importProduct( }); } + if (product.price.selfConfigureAmount !== null && product.price.currency) { + await upsertPriceObservation({ + transceiverId, + sourceVendorId: vendorId, + price: product.price.selfConfigureAmount, + currency: product.price.currency, + marketplace: "flexoptix-selfconfigure", + stockLevel: product.stock.status ?? "unknown", + quantityAvailable: product.stock.quantity ?? undefined, + url: product.url ?? undefined, + contentHash: contentHash({ + source: product.source, + sku: product.sku, + price: product.price.selfConfigureAmount, + currency: product.price.currency, + marketplace: "flexoptix-selfconfigure", + fetchedAt: product.price.fetchedAt, + }), + }); + } + + if (product.price.listAmount !== null && product.price.currency) { + await upsertPriceObservation({ + transceiverId, + sourceVendorId: vendorId, + price: product.price.listAmount, + currency: product.price.currency, + marketplace: "flexoptix-msrp", + stockLevel: product.stock.status ?? "unknown", + quantityAvailable: product.stock.quantity ?? undefined, + url: product.url ?? undefined, + contentHash: contentHash({ + source: product.source, + sku: product.sku, + price: product.price.listAmount, + currency: product.price.currency, + marketplace: "flexoptix-msrp", + fetchedAt: product.price.fetchedAt, + }), + }); + } + const stockWritten = await upsertStockObservation({ transceiverId, sourceVendorId: vendorId, @@ -492,6 +545,46 @@ function extractRows(payload: unknown): JsonRecord[] { // ── Main export ──────────────────────────────────────────────────────────── + +// ── Self-configure price enrichment ─────────────────────────────────────── +// The bulk paginated endpoint omits self_configure_price. +// A targeted sku[] fetch returns it. Batch 50 SKUs per call (~10 calls total). + +async function fetchSelfConfigurePrices( + skus: string[], + baseUrl: string, + headers: Record, + currency: string, + timeoutMs: number, +): Promise> { + const BATCH_SIZE = 50; + const priceMap = new Map(); + const productPath = process.env["FLEXOPTIX_API_PRODUCTS_PATH"]?.trim() ?? "/rest/V2/flexoptix/products"; + + for (let i = 0; i < skus.length; i += BATCH_SIZE) { + const batch = skus.slice(i, i + BATCH_SIZE); + const url = buildUrl(baseUrl, productPath); + url.searchParams.set("currency", currency); + batch.forEach(sku => url.searchParams.append("sku[]", sku)); + + try { + const payload = await fetchJson(url, { headers }, timeoutMs); + for (const row of extractRows(payload)) { + if (!isRecord(row)) continue; + const sku = asString(row["sku"] ?? row["SKU"]); + const flat = flatLookup(row); + const scPrice = asNumber(flat["selfconfigureprice"] ?? flat["selfconfigprice"] ?? null); + if (sku && scPrice !== null) priceMap.set(sku, scPrice); + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[${new Date().toISOString()}] Self-configure enrichment batch failed: ${msg.slice(0, 80)}`); + } + } + + return priceMap; +} + export async function syncFlexoptixCatalog(): Promise { const { baseUrl, username, password, token } = validateEnv(); const timeoutMs = parseInt(process.env["FLEXOPTIX_API_TIMEOUT_MS"]?.trim() ?? "30000", 10); @@ -517,7 +610,25 @@ export async function syncFlexoptixCatalog(): Promise { const importable = products.filter(canImportProduct); const skipped = products.length - importable.length; - console.log(`[${new Date().toISOString()}] Normalized: ${products.length} | importable: ${importable.length} | skipped: ${skipped}`); + // Enrich importable products with self_configure_price (not in bulk response) + const currency = process.env["FLEXOPTIX_API_CURRENCY"]?.trim() ?? "EUR"; + const scPrices = await fetchSelfConfigurePrices( + importable.map(p => p.sku), + baseUrl, + headers, + currency, + timeoutMs, + ); + let scEnriched = 0; + for (const product of importable) { + const scPrice = scPrices.get(product.sku); + if (scPrice !== undefined) { + product.price.selfConfigureAmount = scPrice; + scEnriched++; + } + } + + console.log(`[${new Date().toISOString()}] Normalized: ${products.length} | importable: ${importable.length} | skipped: ${skipped} | self-configure enriched: ${scEnriched}`); const vendorId = await ensureVendor("Flexoptix", "compatible", "https://www.flexoptix.net", "https://www.flexoptix.net");