109 lines
4.5 KiB
Python
109 lines
4.5 KiB
Python
|
|
"""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))
|