quote-gen-api / pricing_engine.py
ThatandMoore's picture
Deploy Quote Gen API
1ff060d verified
Raw History Blame Contribute Delete
8.61 kB
"""
PRICING ENGINE v1 β€” the core of the Quote Stack
================================================
What this file IS: a machine that turns a job spec (what the customer
wants) plus a config (your shop's numbers) into a priced quote.
job_example.json ─┐
β”œβ”€β”€> pricing_engine.py ──> quote.json + printed summary
pricing_config.jsonβ”˜
Shop analogy: this file is the CNC. The config is your tool library and
material database. The job file is the work order. You never modify the
machine to run a new job β€” you change the work order. You never modify
the machine when material prices change β€” you update the database.
THAT separation is the whole architecture.
Run it:
python3 pricing_engine.py job_example.json
Every function below follows one rule: data in, data out, no surprises.
Like a jig β€” same input, same output, every time. That's called a
"pure function" and it's why each piece can be tested by itself.
"""
import json # reads/writes .json files (structured data as text)
import sys # lets us read what was typed after the script name
from pathlib import Path # a clean way to handle file paths
# ---------------------------------------------------------------------
# STEP 1 β€” LOADING
# Read a .json file from disk and turn it into a Python dict.
# A dict is just a labeled bin system: dict["tax_rate"] pulls the value
# out of the bin labeled "tax_rate". JSON and dicts are the same idea β€”
# one lives in a file, one lives in memory.
# ---------------------------------------------------------------------
def load_json(path: str) -> dict:
"""Open a JSON file and return its contents as a dict."""
return json.loads(Path(path).read_text())
# ---------------------------------------------------------------------
# STEP 2 β€” MATERIAL COST (per single cabinet)
# Walk the recipe for one cabinet type and price its ingredients:
# sheet goods + edgebanding + hardware. The escalation dial multiplies
# all material costs β€” your oil-shock lever from the config.
# ---------------------------------------------------------------------
def material_cost(cab: dict, config: dict) -> dict:
"""Return the material cost breakdown for ONE cabinet of this type."""
materials = config["materials"]
hardware_prices = config["hardware"]
escalation = 1.0 + config["escalation"]["material_escalation_pct"]
# Sheet goods: cost per square foot * square feet this cabinet uses
ply = materials["PLY_34_PREFIN"]
cost_per_sqft = ply["cost_per_sheet"] / ply["sqft_per_sheet"]
sheet_cost = cost_per_sqft * cab["sheet_sqft"]
# Edgebanding: cost per linear foot * feet used
band_cost = materials["EDGE_PVC"]["cost_per_lf"] * cab["edgeband_lf"]
# Hardware: for each item in the cabinet's hardware recipe,
# look up its unit price and multiply by how many it needs.
hw_cost = 0.0
for item_name, qty_needed in cab["hardware"].items():
hw_cost += hardware_prices[item_name] * qty_needed
return {
"sheet_goods": round(sheet_cost * escalation, 2),
"edgebanding": round(band_cost * escalation, 2),
"hardware": round(hw_cost * escalation, 2),
"total": round((sheet_cost + band_cost + hw_cost) * escalation, 2),
}
# ---------------------------------------------------------------------
# STEP 3 β€” LABOR + MACHINE COST (per single cabinet)
# Hours * rate. Bench labor and CNC time are separate lines because
# they cost different amounts and you'll want to see them separately.
# ---------------------------------------------------------------------
def labor_cost(cab: dict, config: dict) -> dict:
"""Return labor + machine cost for ONE cabinet of this type."""
rates = config["rates"]
bench = cab["build_hrs"] * rates["shop_labor_per_hr"]
cnc = cab["cnc_hrs"] * rates["cnc_machine_per_hr"]
return {
"bench_labor": round(bench, 2),
"cnc_time": round(cnc, 2),
"total": round(bench + cnc, 2),
}
# ---------------------------------------------------------------------
# STEP 4 β€” PRICE ONE LINE ITEM
# Cost -> overhead -> margin -> sell price. Note the margin math:
#
# MARKUP: price = cost * (1 + 35%) -> 35% ON TOP of cost
# MARGIN: price = cost / (1 - 35%) -> 35% OF THE SELL PRICE
#
# These are NOT the same. A 35% markup is only a 25.9% margin.
# Shops that confuse them slowly bleed out. We use true margin.
# ---------------------------------------------------------------------
def price_line(line: dict, config: dict) -> dict:
"""Take one job line ({type, qty}) and return it fully priced."""
cab = config["cabinet_types"][line["type"]]
mat = material_cost(cab, config)
lab = labor_cost(cab, config)
# Direct cost = what one cabinet costs you in materials + labor
unit_direct = mat["total"] + lab["total"]
# Overhead = rent, insurance, electricity... spread as a % of direct
unit_overhead = unit_direct * config["shop"]["overhead_pct"]
unit_cost = unit_direct + unit_overhead
# Sell price using TRUE margin (see note above)
margin = config["shop"]["target_margin_pct"]
unit_price = unit_cost / (1.0 - margin)
qty = line["qty"]
return {
"type": line["type"],
"description": cab["desc"],
"qty": qty,
"unit_materials": mat["total"],
"unit_labor": lab["total"],
"unit_overhead": round(unit_overhead, 2),
"unit_cost": round(unit_cost, 2),
"unit_price": round(unit_price, 2),
"extended_price": round(unit_price * qty, 2),
}
# ---------------------------------------------------------------------
# STEP 5 β€” BUILD THE WHOLE QUOTE
# Loop over every line in the job, price each one, then total up.
# ---------------------------------------------------------------------
def build_quote(job: dict, config: dict) -> dict:
"""Price every line of a job and return the complete quote dict."""
priced_lines = [price_line(line, config) for line in job["lines"]]
subtotal = sum(line["extended_price"] for line in priced_lines)
tax = subtotal * config["shop"]["tax_rate"]
return {
"customer": job["customer"],
"project": job["project"],
"lines": priced_lines,
"subtotal": round(subtotal, 2),
"tax": round(tax, 2),
"total": round(subtotal + tax, 2),
"assumptions": {
"margin_pct": config["shop"]["target_margin_pct"],
"overhead_pct": config["shop"]["overhead_pct"],
"material_escalation_pct": config["escalation"]["material_escalation_pct"],
},
}
# ---------------------------------------------------------------------
# STEP 6 β€” HUMAN-READABLE OUTPUT
# The quote dict is for machines (PandaDoc, your app, the SaaS).
# This printout is for you.
# ---------------------------------------------------------------------
def print_summary(quote: dict) -> None:
"""Print a readable quote summary to the screen."""
print(f"\nQUOTE β€” {quote['project']}")
print(f"Customer: {quote['customer']}")
print("-" * 68)
for ln in quote["lines"]:
print(f"{ln['qty']:>3} x {ln['description']:<38} "
f"@ ${ln['unit_price']:>8,.2f} = ${ln['extended_price']:>10,.2f}")
print("-" * 68)
print(f"{'Subtotal':>56}: ${quote['subtotal']:>10,.2f}")
print(f"{'Tax':>56}: ${quote['tax']:>10,.2f}")
print(f"{'TOTAL':>56}: ${quote['total']:>10,.2f}\n")
# ---------------------------------------------------------------------
# STEP 7 β€” THE MAIN SEQUENCE (runs when you execute this file)
# Load config -> load job -> build quote -> print it -> save quote.json
# ---------------------------------------------------------------------
if __name__ == "__main__":
# sys.argv is the list of words you typed on the command line.
# sys.argv[0] is the script name; sys.argv[1] is the job file.
job_path = sys.argv[1] if len(sys.argv) > 1 else "job_example.json"
config = load_json("pricing_config.json")
job = load_json(job_path)
quote = build_quote(job, config)
print_summary(quote)
# Save the machine-readable version next to the job file
out_path = Path(job_path).with_name("quote.json")
out_path.write_text(json.dumps(quote, indent=2))
print(f"Saved machine-readable quote to: {out_path}")