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