Files
cannaiq/backend/dist/services/availability.js
Kelly 66e07b2009 fix(monitor): remove non-existent worker columns from job_run_logs query
The job_run_logs table tracks scheduled job orchestration, not individual
worker jobs. Worker info (worker_id, worker_hostname) belongs on
dispensary_crawl_jobs, not job_run_logs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 18:45:05 -07:00

202 lines
6.5 KiB
JavaScript

"use strict";
/**
* Availability Service
*
* Normalizes product availability from various menu providers and tracks
* state transitions for inventory analytics.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.normalizeAvailability = normalizeAvailability;
exports.extractAvailabilityHints = extractAvailabilityHints;
exports.hintsToAvailability = hintsToAvailability;
exports.aggregateAvailability = aggregateAvailability;
// Threshold for considering stock as "limited"
const LIMITED_THRESHOLD = 5;
/**
* 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
*/
function normalizeAvailability(dutchieProduct) {
const raw = {};
// 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) => v.quantity !== undefined)
.map((v) => ({ option: v.option, quantity: v.quantity }));
if (variantQuantities.length) {
raw.variantQuantities = variantQuantities;
}
}
// Try to extract quantity
let quantity = 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, v) => {
return sum + (typeof v.quantity === 'number' ? v.quantity : 0);
}, 0);
if (totalVariantQty > 0) {
quantity = totalVariantQty;
}
}
// Determine status
let status = '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
*/
function extractAvailabilityHints(pageContent, productElement) {
const hints = {};
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
*/
function hintsToAvailability(hints) {
let status = 'unknown';
let quantity = 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
};
}
function aggregateAvailability(products) {
const counts = {
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;
}