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>
202 lines
6.5 KiB
JavaScript
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;
|
|
}
|