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;
|
||||
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<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> {
|
||||
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<FlexoptixSyncResult> {
|
||||
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");
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user