Add Polaris FastAPI backend: models/schemas, pricing+scoring+order router, importer, Celery tasks, API routers, seed script

This commit is contained in:
drjones
2026-08-25 20:37:58 -07:00
parent 8573554bf9
commit 96b4fcaac9
32 changed files with 2211 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Polaris business engines."""

View File

@@ -0,0 +1,115 @@
"""Feed importer: normalize -> validate -> upsert.
NEVER auto-publishes products: new products are created with status 'imported'
only. Invalid items (missing sku/title, non-positive cost, negative inventory)
are skipped and logged to audit_logs.
"""
from decimal import Decimal
from app.engines.suppliers.base import build_adapter
from app.models import AuditLog, Product, SupplierProduct
def validate(item):
problems = []
if not (item.get("sku") or "").strip():
problems.append("missing sku")
if not (item.get("title") or "").strip():
problems.append("missing title")
try:
cost = float(item.get("cost") or 0)
if cost <= 0:
problems.append("non-positive cost")
except (TypeError, ValueError):
problems.append("invalid cost")
try:
inventory = int(float(item.get("inventory") or 0))
if inventory < 0:
problems.append("negative inventory")
except (TypeError, ValueError):
problems.append("invalid inventory")
return problems
def import_feed(db, supplier, feed_type=None, data_or_url=""):
"""Import a supplier feed into products + supplier_products.
Returns summary dict: {supplier, received, imported, updated, skipped: [...]}
"""
adapter = build_adapter(supplier, data_or_url=data_or_url)
items = adapter.get_products()
summary = {
"supplier": supplier.name,
"supplier_id": str(supplier.id),
"received": len(items),
"imported": 0,
"updated": 0,
"skipped": [],
}
for item in items:
problems = validate(item)
if problems:
reason = "; ".join(problems)
summary["skipped"].append({"sku": item.get("sku"), "reason": reason})
db.add(
AuditLog(
actor="importer",
action="import_skipped",
entity="product",
entity_id=item.get("sku"),
detail={"supplier": supplier.name, "reason": reason},
)
)
continue
product = db.query(Product).filter(Product.sku == item["sku"]).first()
if product is None:
product = Product(sku=item["sku"], status="imported")
db.add(product)
summary["imported"] += 1
else:
summary["updated"] += 1
# set all required fields BEFORE flushing (title is NOT NULL in schema)
product.title = item["title"]
product.description = item.get("description") or product.description
product.category = item.get("category") or product.category
product.brand = item.get("brand") or product.brand
product.cost = Decimal(str(item["cost"]))
if item.get("image_url"):
images = list(product.images or [])
if item["image_url"] not in images:
images.append(item["image_url"])
product.images = images
db.flush() # product.id available now
sp = (
db.query(SupplierProduct)
.filter(
SupplierProduct.supplier_id == supplier.id,
SupplierProduct.supplier_sku == item["sku"],
)
.first()
)
if sp is None:
sp = SupplierProduct(
supplier_id=supplier.id,
supplier_sku=item["sku"],
product_id=product.id,
)
db.add(sp)
db.flush()
else:
sp.product_id = product.id
sp.supplier_cost = Decimal(str(item["cost"]))
sp.supplier_inventory = int(item.get("inventory") or 0)
sp.shipping_cost = Decimal(str(item.get("shipping_cost") or 0))
sp.shipping_time = item.get("shipping_time")
db.commit()
return summary

View File

@@ -0,0 +1,72 @@
"""Inventory sync engine.
Refreshes supplier_products inventory from each adapter, writes inventory_history,
and flags zero/unavailable stock (pauses published products + audit log alert).
"""
from datetime import datetime, timezone
from app.engines.suppliers.base import build_adapter
from app.models import AuditLog, InventoryHistory, Supplier, SupplierProduct
def sync_inventory(db, supplier=None):
if supplier is not None:
suppliers = [supplier]
else:
suppliers = db.query(Supplier).filter(Supplier.status == "active").all()
stats = {"suppliers": len(suppliers), "checked": 0, "changed": 0, "zeroed": 0}
for s in suppliers:
try:
adapter = build_adapter(s)
current = {r["supplier_sku"]: int(r["inventory"]) for r in adapter.get_inventory()}
except Exception as exc: # noqa: BLE001 — log and continue to next supplier
db.add(
AuditLog(
actor="inventory",
action="sync_failed",
entity="supplier",
entity_id=str(s.id),
detail={"supplier": s.name, "error": str(exc)},
)
)
continue
for sp in db.query(SupplierProduct).filter(SupplierProduct.supplier_id == s.id).all():
stats["checked"] += 1
new_value = current.get(sp.supplier_sku)
if new_value is None:
continue
if sp.supplier_inventory != new_value:
stats["changed"] += 1
sp.supplier_inventory = new_value
sp.last_updated = datetime.now(timezone.utc)
db.add(InventoryHistory(supplier_product_id=sp.id, inventory=new_value))
if new_value <= 0:
stats["zeroed"] += 1
db.add(
AuditLog(
actor="inventory",
action="inventory_zero",
entity="supplier_product",
entity_id=str(sp.id),
detail={"supplier_sku": sp.supplier_sku, "inventory": 0},
)
)
if sp.product is not None and sp.product.status == "published":
sp.product.status = "paused"
db.add(
AuditLog(
actor="inventory",
action="product_paused_zero_inventory",
entity="product",
entity_id=str(sp.product.id),
detail={"sku": sp.product.sku},
)
)
db.commit()
return stats

View File

@@ -0,0 +1,122 @@
"""Order router — select highest-scoring supplier and record profit.
select_supplier: eligible = active supplier, linked supplier_product, stock >= qty.
route_order: assigns supplier, computes supplier_cost / shipping_cost / fees,
records net profit = retail_total - supplier_cost - shipping_cost - fees.
"""
from decimal import ROUND_HALF_UP, Decimal
from app.engines import scoring
from app.models import AuditLog, Product, Supplier, SupplierProduct
CENT = Decimal("0.01")
FEE_RATE = Decimal("0.05") # payment processing estimate
FEE_FLAT = Decimal("0.30")
def _f(value) -> float:
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
def select_supplier(db, product_id, qty):
"""Return (result_dict, error_str). result = {supplier, supplier_product, score}."""
product = db.query(Product).filter(Product.id == product_id).first()
if product is None:
return None, "product not found"
rows = (
db.query(SupplierProduct, Supplier)
.join(Supplier, SupplierProduct.supplier_id == Supplier.id)
.filter(
SupplierProduct.product_id == product_id,
Supplier.status == "active",
SupplierProduct.supplier_inventory >= qty,
)
.all()
)
if not rows:
return None, "no eligible supplier"
pool = {"avg_cost": sum(_f(sp.supplier_cost) for sp, _ in rows) / len(rows)}
scored = [
(scoring.score_supplier(db, supplier, sp, qty, pool), sp, supplier)
for sp, supplier in rows
]
best_score, best_sp, best_supplier = max(scored, key=lambda t: t[0])
return {
"supplier": best_supplier,
"supplier_product": best_sp,
"score": best_score,
}, None
def route_order(db, order):
"""Route a newly created order to the best supplier(s) and record profit."""
supplier_id = None
supplier_cost = Decimal("0")
shipping_cost = Decimal("0")
routing_detail = []
for item in order.items or []:
result, err = select_supplier(db, item["product_id"], int(item["qty"]))
if err:
routing_detail.append({"product_id": item["product_id"], "error": err})
continue
sp = result["supplier_product"]
if supplier_id is None:
supplier_id = sp.supplier_id
supplier_cost += Decimal(str(sp.supplier_cost or 0)) * int(item["qty"])
shipping_cost += Decimal(str(sp.shipping_cost or 0))
routing_detail.append(
{
"product_id": item["product_id"],
"supplier": result["supplier"].name,
"supplier_score": result["score"],
}
)
order.supplier_id = supplier_id
order.supplier_cost = supplier_cost.quantize(CENT, rounding=ROUND_HALF_UP)
order.shipping_cost = shipping_cost.quantize(CENT, rounding=ROUND_HALF_UP)
order.fees = (Decimal(str(order.retail_total or 0)) * FEE_RATE + FEE_FLAT).quantize(
CENT, rounding=ROUND_HALF_UP
)
order.profit = (
Decimal(str(order.retail_total or 0))
- order.supplier_cost
- order.shipping_cost
- order.fees
).quantize(CENT, rounding=ROUND_HALF_UP)
db.add(
AuditLog(
actor="order_router",
action="order_routed",
entity="order",
entity_id=order.order_number,
detail={
"supplier_id": str(supplier_id) if supplier_id else None,
"profit": float(order.profit),
"routing": routing_detail,
},
)
)
if supplier_id is None:
order.status = "new" # stays unfulfilled; no eligible supplier
db.commit()
return {"routed": False, "reason": "no eligible supplier for any item"}
order.status = "confirmed"
db.commit()
return {
"routed": True,
"supplier_id": str(supplier_id),
"profit": float(order.profit),
"supplier_cost": float(order.supplier_cost),
"shipping_cost": float(order.shipping_cost),
"fees": float(order.fees),
}

View File

@@ -0,0 +1,87 @@
"""Pricing engine.
Applies active `price_rules` (cost -> markup bands) and computes net profit:
retail = cost * (1 + markup_pct/100) (floored at cost + shipping + fees)
net profit = retail - cost - shipping - fees
margin_pct = profit / retail * 100
`min_price` (product floor) is respected — retail is never below it.
"""
from decimal import ROUND_HALF_UP, Decimal
from app.models import PriceRule
CENT = Decimal("0.01")
def _q(value) -> Decimal:
try:
return Decimal(str(value or 0))
except Exception:
return Decimal("0")
def get_active_rules(db):
return (
db.query(PriceRule)
.filter(PriceRule.active.is_(True))
.order_by(PriceRule.min_cost)
.all()
)
def find_rule(db, cost):
cost = _q(cost)
for rule in get_active_rules(db):
if cost >= _q(rule.min_cost) and (rule.max_cost is None or cost < _q(rule.max_cost)):
return rule
return None
def calculate_price(db, cost, shipping=0, fees=0, min_price=None):
"""Return dict(retail, profit, margin, margin_pct, markup_pct, rule_id, ...) or None.
None means no active rule applies — callers must NOT publish such products.
"""
cost = _q(cost)
shipping = _q(shipping)
fees = _q(fees)
rule = find_rule(db, cost)
if rule is None:
return None
markup_pct = _q(rule.markup_pct)
retail = (cost * (1 + markup_pct / 100)).quantize(CENT, rounding=ROUND_HALF_UP)
# floor: cost + shipping + fees, raised by min_margin_pct if configured
floor = (cost + shipping + fees).quantize(CENT, rounding=ROUND_HALF_UP)
min_margin_pct = _q(rule.min_margin_pct)
if min_margin_pct > 0:
floor = max(floor, (floor + retail * min_margin_pct / 100).quantize(CENT, rounding=ROUND_HALF_UP))
if retail < floor:
retail = floor
# never below the product's minimum price
if min_price is not None and retail < _q(min_price):
retail = _q(min_price).quantize(CENT, rounding=ROUND_HALF_UP)
profit = (retail - cost - shipping - fees).quantize(CENT, rounding=ROUND_HALF_UP)
margin_pct = (profit / retail * 100).quantize(Decimal("0.01")) if retail > 0 else Decimal("0")
return {
"cost": cost,
"shipping": shipping,
"fees": fees,
"retail": retail,
"profit": profit,
"margin": profit,
"margin_pct": float(margin_pct),
"markup_pct": float(markup_pct),
"rule_id": rule.id,
}
def estimate_net_profit(retail, cost, shipping=0, fees=0):
"""Net profit = retail - (cost + shipping + fees)."""
return (_q(retail) - _q(cost) - _q(shipping) - _q(fees)).quantize(CENT, rounding=ROUND_HALF_UP)

View File

@@ -0,0 +1,70 @@
"""Supplier scoring.
Weighted score (0-100):
40% price — cheaper supplier cost vs. the eligible pool
20% inventory — stock sufficiency for the requested qty
15% speed — shipping_time ('3-5 days' -> avg days)
10% fulfillment — supplier_performance.fulfillment_rate
10% returns — inverse of supplier_performance.return_rate
5% history — reliability_score blended with stock_accuracy
"""
import re
def _num(value, default=0.0):
try:
return float(value or 0)
except (TypeError, ValueError):
return default
def _shipping_days(sp):
text = (sp.shipping_time or "").strip()
numbers = re.findall(r"\d+", text)
if not numbers:
return 5.0
return float(sum(int(n) for n in numbers)) / len(numbers)
def score_supplier(db, supplier, sp, qty, price_pool=None):
perf = supplier.performance
# 1) price (40%) — cheaper than pool average is better
cost = _num(sp.supplier_cost)
if price_pool and price_pool.get("avg_cost"):
avg = price_pool["avg_cost"]
price_score = max(0.0, 100.0 - (cost / avg - 1.0) * 100.0)
else:
price_score = 50.0
# 2) inventory (20%) — can we fill the quantity?
inventory = int(sp.supplier_inventory or 0)
inv_score = 100.0 if inventory >= qty else max(0.0, (inventory / qty) * 100.0)
# 3) speed (15%) — fewer shipping days is better
days = _shipping_days(sp)
speed_score = max(0.0, 100.0 - days * 12.0)
# 4) fulfillment (10%)
fulfillment = 100.0 if perf is None else _num(perf.fulfillment_rate, 100.0)
# 5) returns (10%) — 0% returns = 100 points, 20% returns = 0 points
return_rate = 0.0 if perf is None else _num(perf.return_rate, 0.0)
returns_score = max(0.0, 100.0 - return_rate * 5.0)
# 6) history (5%) — reliability blended with stock accuracy
reliability = _num(supplier.reliability_score, 50.0)
if perf is None:
history_score = reliability
else:
history_score = 0.5 * reliability + 0.5 * _num(perf.stock_accuracy, 100.0)
total = (
0.40 * price_score
+ 0.20 * inv_score
+ 0.15 * speed_score
+ 0.10 * fulfillment
+ 0.10 * returns_score
+ 0.05 * history_score
)
return round(min(100.0, max(0.0, total)), 2)

View File

@@ -0,0 +1 @@
"""Supplier adapter implementations."""

View 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)

View 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"}

View 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"}