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