quote-gen-api / catalog_engine.py
ThatandMoore's picture
Deploy Quote Gen API
1ff060d verified
Raw History Blame Contribute Delete
7.69 kB
"""
CATALOG ENGINE β€” prices a job against the shop's price book
============================================================
What this file IS: the pricing brain for Phase 2. Where pricing_engine.py
builds a price up from materials + labor + margin, THIS engine looks the
price up in your price book (the price_items table in Supabase) and then
applies the shop's three business rules on top:
1. Catalog items: the book price IS the sell price. No margin math.
(+ glass_add if the customer wants glass doors)
2. Buy-out items: entered at what they cost you, marked up 40%.
3. GC / referral: the whole job gets a finder fee (8-10%) added.
Tax is applied last, on top of everything including the finder fee.
job (what the customer wants) ─┐
price_book (SKU -> price) β”œβ”€β”€> build_quote() ──> quote dict
settings (shop percentages) β”€β”˜
Shop analogy: pricing_engine.py is the CNC that cuts from raw stock.
This file is the parts counter β€” you hand it a SKU, it hands you a price.
Nothing in this file touches the network or the database. It only works
on plain dicts, so it can be tested with a fake price book in a fraction
of a second. The database plumbing lives in db.py; the web layer in api.py.
Percentages here are WHOLE NUMBERS, matching the database:
10 means 10%, not 0.10. (pricing_engine.py uses 0.10 β€” different file,
different convention. Both are documented; don't mix them up.)
"""
# ---------------------------------------------------------------------
# STEP 1 β€” INDEX THE PRICE BOOK
# The database hands us a LIST of rows. Looking up a SKU in a list means
# scanning every row. Turning it into a dict keyed by SKU makes each
# lookup instant β€” like sorting parts into labeled bins once, instead of
# digging through a pile every time.
# ---------------------------------------------------------------------
def index_price_book(rows: list) -> dict:
"""Turn a list of price_items rows into {sku: item} with numeric prices."""
book = {}
for row in rows:
book[row["sku"]] = {
"sku": row["sku"],
"category": row.get("category"),
"description": row.get("description"),
# The database sends numbers as strings sometimes ("700.00");
# float() makes them real numbers we can do math on.
"price": float(row["price"]),
"glass_add": float(row.get("glass_add") or 0),
"needs_verification": bool(row.get("needs_verification", False)),
}
return book
# ---------------------------------------------------------------------
# STEP 2 β€” PRICE ONE CATALOG LINE
# Rule 1: the book price is final. The only adjustment is the glass
# upcharge, and only when the customer asked for glass.
# ---------------------------------------------------------------------
def price_catalog_line(line: dict, price_book: dict) -> dict:
"""Price one catalog line ({sku, qty, with_glass}) from the price book."""
sku = line["sku"]
if sku not in price_book:
# A clear message beats a mysterious KeyError three layers up.
raise LookupError(f"Unknown SKU: {sku!r} is not in the price book")
item = price_book[sku]
with_glass = bool(line.get("with_glass", False))
unit_price = item["price"] + (item["glass_add"] if with_glass else 0.0)
qty = float(line.get("qty", 1))
return {
"kind": "catalog",
"sku": sku,
"description": item["description"],
"with_glass": with_glass,
"qty": qty,
"unit_price": round(unit_price, 2),
"extended_price": round(unit_price * qty, 2),
# Passed through so the estimator sees a flag on iffy prices.
"needs_verification": item["needs_verification"],
}
# ---------------------------------------------------------------------
# STEP 3 β€” PRICE ONE BUY-OUT LINE
# Rule 2: you type in your COST; the engine marks it up. A 40% MARKUP
# means price = cost * 1.40 (this is markup, not margin β€” see the note
# in pricing_engine.py for why those differ). Your shop's rule is
# stated as a markup, so that's what we use here.
# ---------------------------------------------------------------------
def price_buyout_line(line: dict, markup_pct: float) -> dict:
"""Price one buy-out line ({description, cost, qty}) with a markup."""
cost = float(line["cost"])
qty = float(line.get("qty", 1))
unit_price = cost * (1.0 + markup_pct / 100.0)
return {
"kind": "buyout",
"description": line["description"],
"qty": qty,
"unit_cost": round(cost, 2),
"markup_pct": markup_pct,
"unit_price": round(unit_price, 2),
"extended_price": round(unit_price * qty, 2),
}
# ---------------------------------------------------------------------
# STEP 4 β€” BUILD THE WHOLE QUOTE
# Price every line, add them up, then apply Rule 3 (finder fee) and tax.
#
# `settings` are the shop defaults from the database:
# {"finder_fee_pct": 10, "buyout_markup_pct": 40, "tax_pct": 6}
# A job may override any of them (e.g. an 8% fee for a particular GC).
# If the job doesn't say, the shop default wins.
#
# Sales tax is a TOGGLE, not just a number: `apply_tax: false` on a job
# (tax-exempt customer, resale certificate, out-of-state) zeroes the tax
# line while leaving the shop's rate untouched for the next job.
# ---------------------------------------------------------------------
def build_quote(job: dict, price_book: dict, settings: dict) -> dict:
"""Price every line of a job and return the complete quote dict."""
# Job-level override if given, otherwise shop default.
def setting(job_key: str, settings_key: str) -> float:
value = job.get(job_key)
return float(value if value is not None else settings[settings_key])
gc_referral = bool(job.get("gc_referral", False))
apply_tax = bool(job.get("apply_tax", True)) # default: taxed
gc_fee_pct = setting("gc_fee_pct", "finder_fee_pct")
markup_pct = setting("buyout_markup_pct", "buyout_markup_pct")
tax_pct = setting("tax_pct", "tax_pct")
priced_lines = []
for line in job["lines"]:
if line["kind"] == "catalog":
priced_lines.append(price_catalog_line(line, price_book))
elif line["kind"] == "buyout":
priced_lines.append(price_buyout_line(line, markup_pct))
else:
raise ValueError(f"Unknown line kind: {line['kind']!r}")
subtotal = sum(ln["extended_price"] for ln in priced_lines)
# Rule 3: finder fee only on GC/referral jobs, as a % of the subtotal.
finder_fee = subtotal * gc_fee_pct / 100.0 if gc_referral else 0.0
# Tax is charged on the PRE-TAX TOTAL: subtotal + finder fee.
taxable = subtotal + finder_fee
tax = taxable * tax_pct / 100.0 if apply_tax else 0.0
return {
"customer": job.get("customer"),
"project": job.get("project"),
"lines": priced_lines,
"subtotal": round(subtotal, 2),
"gc_referral": gc_referral,
"finder_fee_pct": gc_fee_pct if gc_referral else 0.0,
"finder_fee": round(finder_fee, 2),
"apply_tax": apply_tax,
"tax_pct": tax_pct if apply_tax else 0.0,
"tax": round(tax, 2),
"total": round(taxable + tax, 2),
"assumptions": {
"buyout_markup_pct": markup_pct,
"finder_fee_pct": gc_fee_pct,
"tax_pct": tax_pct,
},
}