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:
parent
91d19e152d
commit
764f16d68f
@ -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");
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user