Spaces:
Paused
Paused
File size: 7,691 Bytes
1ff060d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 | """
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,
},
}
|