/** * Availability Service * * Normalizes product availability from various menu providers and tracks * state transitions for inventory analytics. */ // Threshold for considering stock as "limited" const LIMITED_THRESHOLD = 5; export type AvailabilityStatus = 'in_stock' | 'out_of_stock' | 'limited' | 'unknown'; export interface NormalizedAvailability { status: AvailabilityStatus; quantity: number | null; raw: any; } export interface AvailabilityHints { hasOutOfStockBadge?: boolean; hasLimitedBadge?: boolean; hasInStockBadge?: boolean; stockText?: string; quantityText?: string; } /** * Normalize availability from a Dutchie product * * Dutchie products can have various availability indicators: * - potencyAmount.quantity: explicit stock count * - status: sometimes includes stock status * - variants[].quantity: stock per variant * - isInStock / inStock: boolean flags */ export function normalizeAvailability(dutchieProduct: any): NormalizedAvailability { const raw: any = {}; // Collect raw availability data for debugging if (dutchieProduct.potencyAmount?.quantity !== undefined) { raw.potencyQuantity = dutchieProduct.potencyAmount.quantity; } if (dutchieProduct.status !== undefined) { raw.status = dutchieProduct.status; } if (dutchieProduct.isInStock !== undefined) { raw.isInStock = dutchieProduct.isInStock; } if (dutchieProduct.inStock !== undefined) { raw.inStock = dutchieProduct.inStock; } if (dutchieProduct.variants?.length) { const variantQuantities = dutchieProduct.variants .filter((v: any) => v.quantity !== undefined) .map((v: any) => ({ option: v.option, quantity: v.quantity })); if (variantQuantities.length) { raw.variantQuantities = variantQuantities; } } // Try to extract quantity let quantity: number | null = null; // Check potencyAmount.quantity first (most reliable for Dutchie) if (typeof dutchieProduct.potencyAmount?.quantity === 'number') { quantity = dutchieProduct.potencyAmount.quantity; } // Sum variant quantities if available else if (dutchieProduct.variants?.length) { const totalVariantQty = dutchieProduct.variants.reduce((sum: number, v: any) => { return sum + (typeof v.quantity === 'number' ? v.quantity : 0); }, 0); if (totalVariantQty > 0) { quantity = totalVariantQty; } } // Determine status let status: AvailabilityStatus = 'unknown'; // Explicit boolean flags take precedence if (dutchieProduct.isInStock === false || dutchieProduct.inStock === false) { status = 'out_of_stock'; } else if (dutchieProduct.isInStock === true || dutchieProduct.inStock === true) { status = quantity !== null && quantity <= LIMITED_THRESHOLD ? 'limited' : 'in_stock'; } // Check status string else if (typeof dutchieProduct.status === 'string') { const statusLower = dutchieProduct.status.toLowerCase(); if (statusLower.includes('out') || statusLower.includes('unavailable')) { status = 'out_of_stock'; } else if (statusLower.includes('limited') || statusLower.includes('low')) { status = 'limited'; } else if (statusLower.includes('in') || statusLower.includes('available')) { status = 'in_stock'; } } // Infer from quantity else if (quantity !== null) { if (quantity === 0) { status = 'out_of_stock'; } else if (quantity <= LIMITED_THRESHOLD) { status = 'limited'; } else { status = 'in_stock'; } } return { status, quantity, raw }; } /** * Extract availability hints from page content or product card HTML * * Used for sandbox provider scraping where we don't have structured data */ export function extractAvailabilityHints(pageContent: string, productElement?: string): AvailabilityHints { const hints: AvailabilityHints = {}; const content = (productElement || pageContent).toLowerCase(); // Check for out-of-stock indicators const oosPatterns = [ 'out of stock', 'out-of-stock', 'sold out', 'soldout', 'unavailable', 'not available', 'coming soon', 'notify me' ]; hints.hasOutOfStockBadge = oosPatterns.some(p => content.includes(p)); // Check for limited stock indicators const limitedPatterns = [ 'limited stock', 'limited quantity', 'low stock', 'only \\d+ left', 'few remaining', 'almost gone', 'selling fast' ]; hints.hasLimitedBadge = limitedPatterns.some(p => { if (p.includes('\\d')) { return new RegExp(p, 'i').test(content); } return content.includes(p); }); // Check for in-stock indicators const inStockPatterns = [ 'in stock', 'in-stock', 'add to cart', 'add to bag', 'buy now', 'available' ]; hints.hasInStockBadge = inStockPatterns.some(p => content.includes(p)); // Try to extract quantity text const qtyMatch = content.match(/(\d+)\s*(left|remaining|in stock|available)/i); if (qtyMatch) { hints.quantityText = qtyMatch[0]; } // Look for explicit stock text const stockTextMatch = content.match(/(out of stock|in stock|low stock|limited|sold out)[^<]*/i); if (stockTextMatch) { hints.stockText = stockTextMatch[0].trim(); } return hints; } /** * Convert availability hints to normalized availability */ export function hintsToAvailability(hints: AvailabilityHints): NormalizedAvailability { let status: AvailabilityStatus = 'unknown'; let quantity: number | null = null; // Extract quantity if present if (hints.quantityText) { const match = hints.quantityText.match(/(\d+)/); if (match) { quantity = parseInt(match[1], 10); } } // Determine status from hints if (hints.hasOutOfStockBadge) { status = 'out_of_stock'; } else if (hints.hasLimitedBadge) { status = 'limited'; } else if (hints.hasInStockBadge) { status = quantity !== null && quantity <= LIMITED_THRESHOLD ? 'limited' : 'in_stock'; } return { status, quantity, raw: hints }; } /** * Aggregate availability counts from a list of products */ export interface AvailabilityCounts { in_stock: number; out_of_stock: number; limited: number; unknown: number; changed: number; } export function aggregateAvailability( products: Array<{ availability_status?: AvailabilityStatus; previous_status?: AvailabilityStatus }> ): AvailabilityCounts { const counts: AvailabilityCounts = { in_stock: 0, out_of_stock: 0, limited: 0, unknown: 0, changed: 0 }; for (const product of products) { const status = product.availability_status || 'unknown'; counts[status]++; if (product.previous_status && product.previous_status !== status) { counts.changed++; } } return counts; }