- Fix column name from s.dutchie_plus_url to s.dutchie_url - Add availability tracking and product freshness APIs - Add crawl script for sequential dispensary processing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
241 lines
6.6 KiB
TypeScript
241 lines
6.6 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|