Files
handoff-pro/scripts/estimate_generator.py

109 lines
4.5 KiB
Python
Raw Normal View History

"""Estimate generator: parse job description into structured estimate."""
import json, sys, os, re
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from lib.db import get_db, init_db
from scripts.pricing_catalog import get_catalog, list_catalogs
from scripts.project_mgmt import create_job, create_estimate
# Common construction scope patterns → (category, unit, base_hours_per_unit)
SCOPE_PATTERNS = {
r"cabinet": ("finish_carpentry", "lf", 2.5),
r"countertop": ("tile", "sf", 0.5),
r"floor": ("flooring", "sf", 0.15),
r"tile": ("tile", "sf", 0.25),
r"paint": ("painting", "sf", 0.05),
r"drywall": ("drywall", "sf", 0.1),
r"electric": ("electrical", "ea", 4),
r"plumb": ("plumbing", "ea", 6),
r"frame|wall": ("framing", "lf", 1.5),
r"demo": ("demolition", "sf", 0.08),
r"roof": ("roofing", "sf", 0.12),
r"concrete|foundation": ("concrete", "cy", 8),
r"window": ("finish_carpentry", "ea", 3),
r"door": ("finish_carpentry", "ea", 4),
r"deck": ("framing", "sf", 0.3),
r"bathroom": ("plumbing", "ea", 40),
r"kitchen": ("general_labor", "ea", 120),
}
# Material cost estimates per unit
MATERIAL_COSTS = {
"cabinet": 250, "countertop": 75, "flooring": 8, "tile": 12,
"paint": 0.50, "drywall": 3, "electrical": 150, "plumbing": 200,
"framing": 5, "demolition": 2, "roofing": 6, "concrete": 180,
"window": 600, "door": 400, "deck": 15, "bathroom": 3000, "kitchen": 8000,
}
def generate(description, job_id=None, catalog_id=None, markup_percent=20):
"""Generate estimate from text description."""
# Get pricing catalog
if catalog_id:
catalog = get_catalog(catalog_id)
else:
cats = list_catalogs()
catalog = cats["catalogs"][0] if cats["catalogs"] else None
rates = catalog.get("labor_rates", {}) if catalog else {}
markups = catalog.get("material_markups", {}) if catalog else {}
# Extract quantities from description
sqft_match = re.search(r"(\d+)\s*(?:sq\s*ft|sqft|sf)", description, re.I)
sqft = int(sqft_match.group(1)) if sqft_match else 200 # default
# Build line items from description
line_items = []
desc_lower = description.lower()
matched = False
for pattern, (category, unit, hours_per) in SCOPE_PATTERNS.items():
if re.search(pattern, desc_lower):
matched = True
qty = sqft if unit == "sf" else (sqft / 10 if unit == "lf" else 1)
labor_rate = rates.get(category, 75)
labor_hours = qty * hours_per
labor_cost = labor_hours * labor_rate
# Material cost
mat_key = next((k for k in MATERIAL_COSTS if re.search(pattern, k)), None)
mat_cost = MATERIAL_COSTS.get(mat_key, 10) * qty if mat_key else qty * 10
mat_markup = markups.get(category, 1.15)
mat_cost *= mat_markup
line_items.append({
"description": f"{category.replace('_',' ').title()}{pattern.replace('|','/')}",
"category": "labor", "quantity": round(labor_hours, 1),
"unit": "hr", "unit_cost": labor_rate
})
line_items.append({
"description": f"Materials — {pattern.replace('|','/')}",
"category": "materials", "quantity": round(qty, 1),
"unit": unit, "unit_cost": round(mat_cost / qty, 2)
})
if not matched:
# Generic estimate based on sqft
line_items = [
{"description": "General Labor", "category": "labor", "quantity": sqft * 0.2, "unit": "hr", "unit_cost": rates.get("general_labor", 55)},
{"description": "Materials (general)", "category": "materials", "quantity": sqft, "unit": "sf", "unit_cost": 15},
]
# Create job if needed
if not job_id:
job_result = create_job("TBD", description=description)
job_id = job_result["id"]
# Create estimate
result = create_estimate(job_id, line_items, markup_percent=markup_percent)
result["line_items_count"] = len(line_items)
result["description"] = description
return result
if __name__ == "__main__":
init_db()
if len(sys.argv) < 2:
print(json.dumps({"error": "usage: estimate_generator.py <description> [catalog_id] [job_id]"}))
else:
desc = sys.argv[1]
cat_id = sys.argv[2] if len(sys.argv) > 2 else None
jid = sys.argv[3] if len(sys.argv) > 3 else None
print(json.dumps(generate(desc, job_id=jid, catalog_id=cat_id), indent=2))