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