Spaces:
Paused
Paused
File size: 8,614 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 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | """
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}")
|