feat(flexoptix): capture self_configure_price + use API type field

- Add two-phase sync: bulk catalog fetch then targeted batch enrichment
  (50 SKUs/call, ~10 extra calls) to retrieve self_configure_price field
  which is absent from paginated responses
- Write self_configure_price as flexoptix-selfconfigure marketplace rows
  in price_observations (343/346 products enriched on first run)
- Add list_price support (flexoptix-msrp marketplace) for future use
- Use API-provided type field (Transceiver/Cables/Accessories) in
  categoryFor() instead of inferring from title alone
- Add productType field to CatalogProduct interface
This commit is contained in:
root 2026-07-07 12:22:33 +00:00
parent 91d19e152d
commit 764f16d68f

View File

@ -40,8 +40,11 @@ interface CatalogProduct {
title: string; title: string;
url: string | null; url: string | null;
imageUrl: string | null; imageUrl: string | null;
productType: string | null;
price: { price: {
amount: number | null; amount: number | null;
listAmount: number | null;
selfConfigureAmount: number | null;
currency: string | null; currency: string | null;
source: "api" | "missing"; source: "api" | "missing";
fetchedAt: string; fetchedAt: string;
@ -255,7 +258,10 @@ function normalizeProduct(row: JsonRecord, fetchedAt: string): CatalogProduct |
const url = asString(pick(flat, ["url", "productUrl", "canonicalUrl", "link"])); const url = asString(pick(flat, ["url", "productUrl", "canonicalUrl", "link"]));
const imageUrl = asString(pick(flat, ["image", "imageUrl", "productImage", "thumbnail"])); 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 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"])) const currency = asString(pick(flat, ["currency", "priceCurrency", "currencyCode"]))
?? (amount === null ? null : process.env["FLEXOPTIX_API_CURRENCY"]?.trim() ?? "EUR"); ?? (amount === null ? null : process.env["FLEXOPTIX_API_CURRENCY"]?.trim() ?? "EUR");
const quantity = asNumber(pick(flat, ["stock", "stockQuantity", "quantity", "availableQuantity"])); const quantity = asNumber(pick(flat, ["stock", "stockQuantity", "quantity", "availableQuantity"]));
@ -277,10 +283,13 @@ function normalizeProduct(row: JsonRecord, fetchedAt: string): CatalogProduct |
fetchedAt, fetchedAt,
sku, sku,
title, title,
productType,
url, url,
imageUrl, imageUrl,
price: { price: {
amount, amount,
listAmount: (listAmount !== null && listAmount !== amount) ? listAmount : null,
selfConfigureAmount,
currency, currency,
source: amount === null ? "missing" : "api", source: amount === null ? "missing" : "api",
fetchedAt, fetchedAt,
@ -332,6 +341,8 @@ function speedLabel(speedGbps: number | null): string | undefined {
} }
function categoryFor(product: CatalogProduct): string { function categoryFor(product: CatalogProduct): string {
const apiType = product.productType?.toLowerCase() ?? "";
if (apiType.includes("cable")) return "Cable";
const text = `${product.title} ${product.optics.protocol ?? ""}`.toLowerCase(); const text = `${product.title} ${product.optics.protocol ?? ""}`.toLowerCase();
if (/\bdac\b|direct attach|copper/.test(text)) return "DAC"; if (/\bdac\b|direct attach|copper/.test(text)) return "DAC";
if (/\baoc\b|active optical/.test(text)) return "AOC"; 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({ const stockWritten = await upsertStockObservation({
transceiverId, transceiverId,
sourceVendorId: vendorId, sourceVendorId: vendorId,
@ -492,6 +545,46 @@ function extractRows(payload: unknown): JsonRecord[] {
// ── Main export ──────────────────────────────────────────────────────────── // ── 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<string, string>,
currency: string,
timeoutMs: number,
): Promise<Map<string, number>> {
const BATCH_SIZE = 50;
const priceMap = new Map<string, number>();
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<FlexoptixSyncResult> { export async function syncFlexoptixCatalog(): Promise<FlexoptixSyncResult> {
const { baseUrl, username, password, token } = validateEnv(); const { baseUrl, username, password, token } = validateEnv();
const timeoutMs = parseInt(process.env["FLEXOPTIX_API_TIMEOUT_MS"]?.trim() ?? "30000", 10); const timeoutMs = parseInt(process.env["FLEXOPTIX_API_TIMEOUT_MS"]?.trim() ?? "30000", 10);
@ -517,7 +610,25 @@ export async function syncFlexoptixCatalog(): Promise<FlexoptixSyncResult> {
const importable = products.filter(canImportProduct); const importable = products.filter(canImportProduct);
const skipped = products.length - importable.length; 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"); const vendorId = await ensureVendor("Flexoptix", "compatible", "https://www.flexoptix.net", "https://www.flexoptix.net");