Add Polaris FastAPI backend: models/schemas, pricing+scoring+order router, importer, Celery tasks, API routers, seed script
This commit is contained in:
1
backend/app/engines/suppliers/__init__.py
Normal file
1
backend/app/engines/suppliers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Supplier adapter implementations."""
|
||||
65
backend/app/engines/suppliers/base.py
Normal file
65
backend/app/engines/suppliers/base.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""SupplierAdapter abstract base class + registry."""
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
# Registry of adapter_type string -> adapter class. Populated by register_adapter.
|
||||
ADAPTERS = {}
|
||||
|
||||
|
||||
class SupplierAdapter(ABC):
|
||||
"""Normalized interface every supplier feed must implement.
|
||||
|
||||
Product dict shape (used by the importer):
|
||||
{sku, title, description, category, brand, cost, inventory,
|
||||
shipping_cost, shipping_time, image_url}
|
||||
"""
|
||||
|
||||
adapter_type = "base"
|
||||
|
||||
def __init__(self, supplier, data_or_url=""):
|
||||
self.supplier = supplier
|
||||
self.data_or_url = data_or_url
|
||||
|
||||
@abstractmethod
|
||||
def get_products(self):
|
||||
"""Return list of normalized product dicts."""
|
||||
|
||||
@abstractmethod
|
||||
def get_inventory(self):
|
||||
"""Return list of {'supplier_sku': str, 'inventory': int}."""
|
||||
|
||||
@abstractmethod
|
||||
def get_price(self, sku):
|
||||
"""Return current supplier cost for sku (number) or None."""
|
||||
|
||||
@abstractmethod
|
||||
def create_order(self, items):
|
||||
"""Place an order at the supplier; return {'ref': str, ...}."""
|
||||
|
||||
@abstractmethod
|
||||
def get_order_status(self, ref):
|
||||
"""Return status string for a supplier order ref."""
|
||||
|
||||
@abstractmethod
|
||||
def get_tracking(self, ref):
|
||||
"""Return {'tracking': str, 'carrier': str} or None."""
|
||||
|
||||
@abstractmethod
|
||||
def cancel_order(self, ref):
|
||||
"""Cancel a supplier order; return status dict."""
|
||||
|
||||
|
||||
def register_adapter(cls):
|
||||
ADAPTERS[cls.adapter_type] = cls
|
||||
return cls
|
||||
|
||||
|
||||
def build_adapter(supplier, data_or_url=""):
|
||||
cls = ADAPTERS.get(supplier.adapter_type)
|
||||
if cls is None:
|
||||
# lazy-import concrete adapters so their @register_adapter side effects run
|
||||
from app.engines.suppliers import csv_adapter, sample # noqa: F401
|
||||
|
||||
cls = ADAPTERS.get(supplier.adapter_type)
|
||||
if cls is None:
|
||||
raise ValueError(f"unknown adapter_type '{supplier.adapter_type}' for supplier {supplier.name}")
|
||||
return cls(supplier, data_or_url=data_or_url)
|
||||
78
backend/app/engines/suppliers/csv_adapter.py
Normal file
78
backend/app/engines/suppliers/csv_adapter.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""CSV feed adapter.
|
||||
|
||||
Reads a supplier CSV with columns:
|
||||
sku,title,cost,inventory,shipping_cost,shipping_time,category,image_url
|
||||
(optional: description, brand)
|
||||
`data_or_url` is either raw CSV text or an http(s) URL to fetch.
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
import urllib.request
|
||||
|
||||
from app.engines.suppliers.base import SupplierAdapter, register_adapter
|
||||
|
||||
|
||||
@register_adapter
|
||||
class CSVAdapter(SupplierAdapter):
|
||||
adapter_type = "csv"
|
||||
|
||||
def __init__(self, supplier, data_or_url=""):
|
||||
super().__init__(supplier, data_or_url=data_or_url)
|
||||
self._rows = None
|
||||
|
||||
def _load(self):
|
||||
if self._rows is not None:
|
||||
return
|
||||
src = (self.data_or_url or "").strip()
|
||||
if src.startswith(("http://", "https://")):
|
||||
with urllib.request.urlopen(src, timeout=30) as resp:
|
||||
src = resp.read().decode("utf-8", "replace")
|
||||
self._rows = list(csv.DictReader(io.StringIO(src)))
|
||||
|
||||
def get_products(self):
|
||||
self._load()
|
||||
products = []
|
||||
for row in self._rows:
|
||||
sku = (row.get("sku") or "").strip()
|
||||
title = (row.get("title") or "").strip()
|
||||
if not sku or not title:
|
||||
continue
|
||||
products.append(
|
||||
{
|
||||
"sku": sku,
|
||||
"title": title,
|
||||
"description": (row.get("description") or "").strip() or None,
|
||||
"category": (row.get("category") or "").strip() or None,
|
||||
"brand": (row.get("brand") or "").strip() or None,
|
||||
"cost": float(row.get("cost") or 0),
|
||||
"inventory": int(float(row.get("inventory") or 0)),
|
||||
"shipping_cost": float(row.get("shipping_cost") or 0),
|
||||
"shipping_time": (row.get("shipping_time") or "").strip() or "3-5 days",
|
||||
"image_url": (row.get("image_url") or "").strip() or None,
|
||||
}
|
||||
)
|
||||
return products
|
||||
|
||||
def get_inventory(self):
|
||||
return [
|
||||
{"supplier_sku": p["sku"], "inventory": p["inventory"]}
|
||||
for p in self.get_products()
|
||||
]
|
||||
|
||||
def get_price(self, sku):
|
||||
for p in self.get_products():
|
||||
if p["sku"] == sku:
|
||||
return p["cost"]
|
||||
return None
|
||||
|
||||
def create_order(self, items):
|
||||
return {"adapter": "csv", "ref": f"csv:{len(items)}:items", "items": items}
|
||||
|
||||
def get_order_status(self, ref):
|
||||
return "confirmed"
|
||||
|
||||
def get_tracking(self, ref):
|
||||
return None
|
||||
|
||||
def cancel_order(self, ref):
|
||||
return {"ref": ref, "status": "cancelled"}
|
||||
89
backend/app/engines/suppliers/sample.py
Normal file
89
backend/app/engines/suppliers/sample.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Sample supplier adapter — static catalog of ~25 realistic products.
|
||||
|
||||
Home / gadgets / office niche, retail range $50-$500 after markup.
|
||||
Used by scripts/seed.py to bootstrap a demo supplier.
|
||||
"""
|
||||
from app.engines.suppliers.base import SupplierAdapter, register_adapter
|
||||
|
||||
# (sku, title, category, cost, inventory, shipping_cost, shipping_time, brand)
|
||||
CATALOG = [
|
||||
("SPL-1001", "Aurora LED Desk Lamp with Wireless Charger", "office", 38.00, 42, 6.50, "3-5 days", "Lumina"),
|
||||
("SPL-1002", "ErgoMesh Pro Office Chair", "office", 145.00, 12, 24.00, "5-7 days", "ErgoMesh"),
|
||||
("SPL-1003", "Standing Desk Converter 32\"", "office", 118.00, 20, 19.50, "4-6 days", "DeskUp"),
|
||||
("SPL-1004", "Mechanical Keyboard TKL (Brown Switch)", "office", 62.00, 55, 5.00, "3-5 days", "KeyForge"),
|
||||
("SPL-1005", "Precision Wireless Mouse Pro", "office", 24.00, 120, 3.50, "2-4 days", "CursorPro"),
|
||||
("SPL-1006", "Noise-Cancelling Over-Ear Headphones", "gadgets", 89.00, 38, 8.00, "3-5 days", "Auralis"),
|
||||
("SPL-1007", "Smart Watch Series X (GPS + HR)", "gadgets", 96.00, 27, 7.50, "4-6 days", "PulseTech"),
|
||||
("SPL-1008", "4K Action Camera Waterproof", "gadgets", 132.00, 18, 9.00, "4-6 days", "AdventureCam"),
|
||||
("SPL-1009", "Mini Bluetooth Speaker 20W", "gadgets", 31.00, 90, 4.00, "2-4 days", "SoundPod"),
|
||||
("SPL-1010", "Fast Wireless Charging Pad 15W", "gadgets", 38.00, 200, 2.50, "2-4 days", "VoltEdge"),
|
||||
("SPL-1011", "Smart Home Hub with Voice Control", "home", 74.00, 33, 6.00, "3-5 days", "NestLink"),
|
||||
("SPL-1012", "Robot Vacuum S5 (Self-Charging)", "home", 210.00, 9, 22.00, "6-8 days", "CleanBot"),
|
||||
("SPL-1013", "HEPA Air Purifier for Large Rooms", "home", 128.00, 15, 18.00, "5-7 days", "PureAir"),
|
||||
("SPL-1014", "Compact Espresso Machine 15-Bar", "home", 178.00, 8, 21.00, "5-7 days", "BrewMaster"),
|
||||
("SPL-1015", "Cordless Stick Vacuum with LED Light", "home", 155.00, 11, 20.00, "5-7 days", "CleanBot"),
|
||||
("SPL-1016", "Smart Thermostat (WiFi)", "home", 92.00, 40, 5.50, "3-5 days", "NestLink"),
|
||||
("SPL-1017", "Smart LED Strip Kit 5m (RGB)", "home", 38.00, 140, 3.00, "2-4 days", "GlowSpace"),
|
||||
("SPL-1018", "Gooseneck Electric Kettle 1L", "home", 44.00, 60, 7.00, "3-5 days", "BrewMaster"),
|
||||
("SPL-1019", "Cast Iron Dutch Oven 6qt", "home", 68.00, 25, 16.00, "5-7 days", "Hearthstone"),
|
||||
("SPL-1020", "Bamboo Electric Standing Desk 55\"", "office", 265.00, 6, 38.00, "7-10 days", "DeskUp"),
|
||||
("SPL-1021", "Ultrawide Monitor 34\" 3440x1440", "office", 310.00, 7, 32.00, "6-8 days", "ViewMax"),
|
||||
("SPL-1022", "Duplex Document Scanner", "office", 98.00, 22, 11.00, "4-6 days", "ScanFast"),
|
||||
("SPL-1023", "Aluminum Laptop Stand (Adjustable)", "office", 38.00, 85, 4.50, "2-4 days", "DeskUp"),
|
||||
("SPL-1024", "Smart Body Composition Scale", "home", 38.00, 70, 5.00, "3-5 days", "PulseTech"),
|
||||
("SPL-1025", "Ergonomic Footrest with Massage", "office", 40.00, 48, 6.00, "3-5 days", "ErgoMesh"),
|
||||
("SPL-1026", "USB-C Hub 8-in-1 (4K HDMI)", "gadgets", 38.00, 110, 3.00, "2-4 days", "VoltEdge"),
|
||||
("SPL-1027", "Portable Power Station 300Wh", "gadgets", 348.00, 5, 34.00, "7-10 days", "VoltEdge"),
|
||||
]
|
||||
|
||||
DESCRIPTIONS = {
|
||||
"office": "Professional-grade office equipment designed for comfort and productivity.",
|
||||
"home": "Thoughtfully designed home essentials that blend function with style.",
|
||||
"gadgets": "Modern tech gadget with reliable performance and everyday practicality.",
|
||||
}
|
||||
|
||||
|
||||
@register_adapter
|
||||
class SampleSupplierAdapter(SupplierAdapter):
|
||||
adapter_type = "sample"
|
||||
|
||||
def get_products(self):
|
||||
return [
|
||||
{
|
||||
"sku": sku,
|
||||
"title": title,
|
||||
"description": DESCRIPTIONS[category],
|
||||
"category": category,
|
||||
"brand": brand,
|
||||
"cost": cost,
|
||||
"inventory": inventory,
|
||||
"shipping_cost": shipping_cost,
|
||||
"shipping_time": shipping_time,
|
||||
"image_url": f"https://placehold.co/600x600/png?text={sku}",
|
||||
}
|
||||
for (sku, title, category, cost, inventory, shipping_cost, shipping_time, brand) in CATALOG
|
||||
]
|
||||
|
||||
def get_inventory(self):
|
||||
return [
|
||||
{"supplier_sku": sku, "inventory": inventory}
|
||||
for (sku, _, _, _, inventory, _, _, _) in CATALOG
|
||||
]
|
||||
|
||||
def get_price(self, sku):
|
||||
for row in CATALOG:
|
||||
if row[0] == sku:
|
||||
return row[3]
|
||||
return None
|
||||
|
||||
def create_order(self, items):
|
||||
return {"adapter": "sample", "ref": f"sample:{len(items)}:items", "items": items}
|
||||
|
||||
def get_order_status(self, ref):
|
||||
return "confirmed"
|
||||
|
||||
def get_tracking(self, ref):
|
||||
return None
|
||||
|
||||
def cancel_order(self, ref):
|
||||
return {"ref": ref, "status": "cancelled"}
|
||||
Reference in New Issue
Block a user