Initial commit: Handoff Pro MCP server for Kellow Construction
This commit is contained in:
0
scripts/__init__.py
Normal file
0
scripts/__init__.py
Normal file
30
scripts/activity_logger.py
Normal file
30
scripts/activity_logger.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Client activity logger: track views, approvals, signatures, payments."""
|
||||
import json, sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from lib.db import get_db, new_id, now, rows_to_list
|
||||
|
||||
def log(action, estimate_id=None, job_id=None, metadata=None):
|
||||
aid = new_id()
|
||||
conn = get_db()
|
||||
conn.execute("INSERT INTO client_activity (id, estimate_id, job_id, action, metadata, timestamp) VALUES (?,?,?,?,?,?)",
|
||||
(aid, estimate_id, job_id, action, json.dumps(metadata) if metadata else None, now()))
|
||||
conn.commit(); conn.close()
|
||||
return {"id": aid, "action": action, "timestamp": now()}
|
||||
|
||||
def get(estimate_id=None, job_id=None):
|
||||
conn = get_db()
|
||||
if estimate_id:
|
||||
rows = conn.execute("SELECT * FROM client_activity WHERE estimate_id=? ORDER BY timestamp DESC", (estimate_id,)).fetchall()
|
||||
elif job_id:
|
||||
rows = conn.execute("SELECT * FROM client_activity WHERE job_id=? ORDER BY timestamp DESC", (job_id,)).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM client_activity ORDER BY timestamp DESC LIMIT 50").fetchall()
|
||||
conn.close()
|
||||
return {"activity": rows_to_list(rows)}
|
||||
|
||||
if __name__ == "__main__":
|
||||
from lib.db import init_db; init_db()
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "get"
|
||||
if cmd == "log": print(json.dumps(log(sys.argv[2], estimate_id=sys.argv[3] if len(sys.argv)>3 else None)))
|
||||
elif cmd == "get": print(json.dumps(get(estimate_id=sys.argv[2] if len(sys.argv)>2 else None), indent=2))
|
||||
else: print(json.dumps({"error": f"unknown: {cmd}"}))
|
||||
40
scripts/budget_tracker.py
Normal file
40
scripts/budget_tracker.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Budget tracker: compare estimated vs actual costs."""
|
||||
import json, sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from lib.db import get_db, row_to_dict, rows_to_list
|
||||
|
||||
def report(job_id):
|
||||
conn = get_db()
|
||||
job = row_to_dict(conn.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone())
|
||||
if not job: conn.close(); return {"error": "job not found"}
|
||||
estimates = rows_to_list(conn.execute("SELECT * FROM estimates WHERE job_id=?", (job_id,)).fetchall())
|
||||
logs = rows_to_list(conn.execute("SELECT * FROM daily_logs WHERE job_id=?", (job_id,)).fetchall())
|
||||
invoices = rows_to_list(conn.execute("SELECT * FROM invoices WHERE job_id=?", (job_id,)).fetchall())
|
||||
conn.close()
|
||||
|
||||
budgeted = sum(e["total_cost"] for e in estimates) if estimates else 0
|
||||
actual_hours = sum(l["hours_worked"] for l in logs)
|
||||
budgeted_hours = sum(e["labor_hours"] for e in estimates) if estimates else 0
|
||||
invoiced = sum(i["amount_due"] for i in invoices)
|
||||
paid = sum(i["amount_paid"] or 0 for i in invoices)
|
||||
|
||||
hours_variance = actual_hours - budgeted_hours
|
||||
hours_pct = (hours_variance / budgeted_hours * 100) if budgeted_hours else 0
|
||||
|
||||
flags = []
|
||||
if hours_pct > 10: flags.append(f"⚠️ Labor {hours_pct:.0f}% over budget")
|
||||
if paid < invoiced * 0.5 and invoices: flags.append("⚠️ Less than 50% collected")
|
||||
|
||||
return {
|
||||
"job_id": job_id, "client": job.get("client_name"),
|
||||
"budget": {"total_estimated": budgeted, "budgeted_hours": budgeted_hours},
|
||||
"actual": {"hours_worked": actual_hours, "log_entries": len(logs)},
|
||||
"financial": {"invoiced": invoiced, "collected": paid, "outstanding": invoiced - paid},
|
||||
"variance": {"hours": hours_variance, "hours_pct": round(hours_pct, 1)},
|
||||
"flags": flags
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
from lib.db import init_db; init_db()
|
||||
if len(sys.argv) < 2: print(json.dumps({"error": "usage: budget_tracker.py <job_id>"}))
|
||||
else: print(json.dumps(report(sys.argv[1]), indent=2))
|
||||
43
scripts/daily_log.py
Normal file
43
scripts/daily_log.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Daily job log management."""
|
||||
import json, sys, os
|
||||
from datetime import date
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from lib.db import get_db, new_id, now, row_to_dict, rows_to_list
|
||||
|
||||
def create(job_id, notes, crew_names=None, hours_worked=None, photos=None):
|
||||
lid = new_id()
|
||||
conn = get_db()
|
||||
conn.execute("""INSERT INTO daily_logs (id, job_id, log_date, crew_names, hours_worked, notes, photos, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?)""",
|
||||
(lid, job_id, date.today().isoformat(), crew_names, hours_worked or 0, notes,
|
||||
json.dumps(photos) if photos else None, now()))
|
||||
conn.commit(); conn.close()
|
||||
return {"id": lid, "job_id": job_id, "date": date.today().isoformat()}
|
||||
|
||||
def list_logs(job_id):
|
||||
conn = get_db()
|
||||
rows = conn.execute("SELECT * FROM daily_logs WHERE job_id=? ORDER BY log_date DESC", (job_id,)).fetchall()
|
||||
conn.close()
|
||||
result = rows_to_list(rows)
|
||||
for r in result:
|
||||
r["photos"] = json.loads(r["photos"]) if r["photos"] else []
|
||||
return {"logs": result}
|
||||
|
||||
def get_log(log_id):
|
||||
conn = get_db()
|
||||
row = row_to_dict(conn.execute("SELECT * FROM daily_logs WHERE id=?", (log_id,)).fetchone())
|
||||
conn.close()
|
||||
if not row: return {"error": "log not found"}
|
||||
row["photos"] = json.loads(row["photos"]) if row["photos"] else []
|
||||
return row
|
||||
|
||||
if __name__ == "__main__":
|
||||
from lib.db import init_db; init_db()
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "list"
|
||||
if cmd == "create":
|
||||
print(json.dumps(create(sys.argv[2], sys.argv[3],
|
||||
sys.argv[4] if len(sys.argv)>4 else None,
|
||||
float(sys.argv[5]) if len(sys.argv)>5 else None)))
|
||||
elif cmd == "list": print(json.dumps(list_logs(sys.argv[2]), indent=2))
|
||||
elif cmd == "get": print(json.dumps(get_log(sys.argv[2]), indent=2))
|
||||
else: print(json.dumps({"error": f"unknown: {cmd}"}))
|
||||
108
scripts/estimate_generator.py
Normal file
108
scripts/estimate_generator.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""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))
|
||||
11
scripts/init_db.py
Normal file
11
scripts/init_db.py
Normal file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Initialize Handoff Pro database and seed default data."""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from lib.db import init_db
|
||||
from scripts.pricing_catalog import seed_default
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
result = seed_default()
|
||||
print(f"Database initialized. Default catalog: {result}")
|
||||
81
scripts/invoice_mgmt.py
Normal file
81
scripts/invoice_mgmt.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Invoice management: create, track, sync to JobTread + QBO."""
|
||||
import json, sys, os
|
||||
from datetime import datetime, timedelta
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from lib.db import get_db, new_id, now, row_to_dict, rows_to_list
|
||||
|
||||
def create(estimate_id, due_date=None):
|
||||
conn = get_db()
|
||||
est = row_to_dict(conn.execute("SELECT * FROM estimates WHERE id=?", (estimate_id,)).fetchone())
|
||||
if not est: conn.close(); return {"error": "estimate not found"}
|
||||
# Generate invoice number
|
||||
count = conn.execute("SELECT COUNT(*) as c FROM invoices").fetchone()["c"]
|
||||
inv_num = f"KC-{count + 1001}"
|
||||
if not due_date:
|
||||
due_date = (datetime.now() + timedelta(days=30)).strftime("%Y-%m-%d")
|
||||
inv_id = new_id()
|
||||
conn.execute("""INSERT INTO invoices (id, estimate_id, job_id, invoice_number, amount_due, due_date, status, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?)""",
|
||||
(inv_id, estimate_id, est["job_id"], inv_num, est["total_cost"], due_date, "draft", now()))
|
||||
conn.commit(); conn.close()
|
||||
return {"id": inv_id, "invoice_number": inv_num, "amount_due": est["total_cost"], "due_date": due_date, "status": "draft"}
|
||||
|
||||
def list_invoices(job_id=None):
|
||||
conn = get_db()
|
||||
if job_id:
|
||||
rows = conn.execute("SELECT * FROM invoices WHERE job_id=? ORDER BY created_at DESC", (job_id,)).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM invoices ORDER BY created_at DESC").fetchall()
|
||||
conn.close()
|
||||
return {"invoices": rows_to_list(rows)}
|
||||
|
||||
def get(invoice_id):
|
||||
conn = get_db()
|
||||
inv = row_to_dict(conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone())
|
||||
if not inv: conn.close(); return {"error": "invoice not found"}
|
||||
est = row_to_dict(conn.execute("SELECT * FROM estimates WHERE id=?", (inv["estimate_id"],)).fetchone())
|
||||
if est:
|
||||
inv["line_items"] = rows_to_list(conn.execute("SELECT * FROM line_items WHERE estimate_id=?", (inv["estimate_id"],)).fetchall())
|
||||
job = row_to_dict(conn.execute("SELECT * FROM jobs WHERE id=?", (inv["job_id"],)).fetchone())
|
||||
inv["job"] = job
|
||||
conn.close()
|
||||
return inv
|
||||
|
||||
def mark_paid(invoice_id, amount_paid=None):
|
||||
conn = get_db()
|
||||
inv = row_to_dict(conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone())
|
||||
if not inv: conn.close(); return {"error": "invoice not found"}
|
||||
paid = amount_paid if amount_paid else inv["amount_due"]
|
||||
conn.execute("UPDATE invoices SET amount_paid=?, status='paid' WHERE id=?", (paid, invoice_id))
|
||||
conn.commit(); conn.close()
|
||||
return {"ok": True, "id": invoice_id, "amount_paid": paid, "status": "paid"}
|
||||
|
||||
def sync_to_qbo(invoice_id):
|
||||
"""Sync invoice to QuickBooks Online."""
|
||||
conn = get_db()
|
||||
inv = row_to_dict(conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone())
|
||||
if not inv: conn.close(); return {"error": "invoice not found"}
|
||||
items = rows_to_list(conn.execute("SELECT * FROM line_items WHERE estimate_id=?", (inv["estimate_id"],)).fetchall())
|
||||
conn.close()
|
||||
try:
|
||||
from lib.qbo import create_invoice as qbo_create
|
||||
qbo_lines = [{"description": i["description"], "amount": i["total"], "qty": i["quantity"]} for i in items]
|
||||
# Note: customer_id mapping needed — for now use placeholder
|
||||
result = qbo_create("1", qbo_lines, inv["due_date"])
|
||||
qbo_id = result.get("Invoice", {}).get("Id")
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE invoices SET qbo_invoice_id=?, synced_to_qbo_at=? WHERE id=?", (qbo_id, now(), invoice_id))
|
||||
conn.commit(); conn.close()
|
||||
return {"ok": True, "qbo_invoice_id": qbo_id}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "msg": "QBO sync failed — check token status"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
from lib.db import init_db; init_db()
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "list"
|
||||
if cmd == "create": print(json.dumps(create(sys.argv[2], sys.argv[3] if len(sys.argv)>3 else None)))
|
||||
elif cmd == "list": print(json.dumps(list_invoices(sys.argv[2] if len(sys.argv)>2 else None), indent=2))
|
||||
elif cmd == "get": print(json.dumps(get(sys.argv[2]), indent=2))
|
||||
elif cmd == "mark-paid": print(json.dumps(mark_paid(sys.argv[2], float(sys.argv[3]) if len(sys.argv)>3 else None)))
|
||||
elif cmd == "sync-qbo": print(json.dumps(sync_to_qbo(sys.argv[2])))
|
||||
else: print(json.dumps({"error": f"unknown: {cmd}"}))
|
||||
90
scripts/jobtread_sync.py
Normal file
90
scripts/jobtread_sync.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""JobTread bidirectional sync tool."""
|
||||
import json, sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from lib.db import get_db, new_id, now, row_to_dict, rows_to_list
|
||||
from lib.pave import pave_query, get_org_id
|
||||
|
||||
def list_customers():
|
||||
org_id = get_org_id()
|
||||
result = pave_query({"organization": {"$": {"id": org_id},
|
||||
"accounts": {"nodes": {"id": {}, "name": {}, "type": {}}}}})
|
||||
accounts = result.get("organization", {}).get("accounts", {}).get("nodes", [])
|
||||
return {"customers": [a for a in accounts if a.get("type") == "customer"]}
|
||||
|
||||
def pull_jobs():
|
||||
"""Pull all jobs from JobTread and upsert to local DB."""
|
||||
org_id = get_org_id()
|
||||
result = pave_query({"organization": {"$": {"id": org_id},
|
||||
"jobs": {"nodes": {"id": {}, "name": {}, "status": {}, "createdAt": {},
|
||||
"account": {"id": {}, "name": {}},
|
||||
"location": {"name": {}, "address": {}}}}}})
|
||||
jobs = result.get("organization", {}).get("jobs", {}).get("nodes", [])
|
||||
conn = get_db()
|
||||
synced = 0
|
||||
for j in jobs:
|
||||
jt_id = j.get("id")
|
||||
existing = conn.execute("SELECT id FROM jobs WHERE jobtread_job_id=?", (jt_id,)).fetchone()
|
||||
if existing:
|
||||
conn.execute("UPDATE jobs SET status=?, updated_at=?, synced_to_jobtread_at=? WHERE jobtread_job_id=?",
|
||||
(j.get("status","active"), now(), now(), jt_id))
|
||||
else:
|
||||
conn.execute("""INSERT INTO jobs (id, client_name, address, description, jobtread_job_id, jobtread_customer_id, status, created_at, updated_at, synced_to_jobtread_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
||||
(new_id(), j.get("account",{}).get("name",""), j.get("location",{}).get("address",""),
|
||||
j.get("name",""), jt_id, j.get("account",{}).get("id",""),
|
||||
j.get("status","active"), j.get("createdAt", now()), now(), now()))
|
||||
synced += 1
|
||||
conn.commit(); conn.close()
|
||||
_log_sync(None, None, "job", "pull", "success")
|
||||
return {"ok": True, "synced": synced, "total_jobs": len(jobs)}
|
||||
|
||||
def push_estimate(estimate_id):
|
||||
"""Push local estimate to JobTread. NOTE: Pave create mutations need verification."""
|
||||
conn = get_db()
|
||||
est = row_to_dict(conn.execute("SELECT * FROM estimates WHERE id=?", (estimate_id,)).fetchone())
|
||||
if not est: conn.close(); return {"error": "estimate not found"}
|
||||
job = row_to_dict(conn.execute("SELECT * FROM jobs WHERE id=?", (est["job_id"],)).fetchone())
|
||||
if not job or not job.get("jobtread_job_id"):
|
||||
conn.close(); return {"error": "job not linked to JobTread"}
|
||||
items = rows_to_list(conn.execute("SELECT * FROM line_items WHERE estimate_id=?", (estimate_id,)).fetchall())
|
||||
conn.close()
|
||||
# Log as pending — actual Pave create mutation TBD (needs Pave Explorer verification)
|
||||
_log_sync(estimate_id, job["jobtread_job_id"], "estimate", "push", "pending",
|
||||
"Pave create document mutation needs verification")
|
||||
return {"ok": True, "status": "pending", "msg": "Estimate queued for JobTread sync. Pave create mutation needs verification via Pave Explorer."}
|
||||
|
||||
def push_invoice(invoice_id):
|
||||
"""Push local invoice to JobTread."""
|
||||
conn = get_db()
|
||||
inv = row_to_dict(conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone())
|
||||
if not inv: conn.close(); return {"error": "invoice not found"}
|
||||
job = row_to_dict(conn.execute("SELECT * FROM jobs WHERE id=?", (inv["job_id"],)).fetchone())
|
||||
conn.close()
|
||||
if not job or not job.get("jobtread_job_id"):
|
||||
return {"error": "job not linked to JobTread"}
|
||||
_log_sync(invoice_id, job["jobtread_job_id"], "invoice", "push", "pending",
|
||||
"Pave create invoice mutation needs verification")
|
||||
return {"ok": True, "status": "pending", "msg": "Invoice queued for JobTread sync."}
|
||||
|
||||
def get_sync_status(job_id):
|
||||
conn = get_db()
|
||||
job = row_to_dict(conn.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone())
|
||||
logs = rows_to_list(conn.execute("SELECT * FROM jobtread_sync_log WHERE local_id=? ORDER BY synced_at DESC LIMIT 10", (job_id,)).fetchall())
|
||||
conn.close()
|
||||
return {"job": job, "sync_log": logs}
|
||||
|
||||
def _log_sync(local_id, jt_id, entity_type, action, status, error=None):
|
||||
conn = get_db()
|
||||
conn.execute("INSERT INTO jobtread_sync_log (id, local_id, jobtread_id, entity_type, action, status, error_message, synced_at) VALUES (?,?,?,?,?,?,?,?)",
|
||||
(new_id(), local_id, jt_id, entity_type, action, status, error, now()))
|
||||
conn.commit(); conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
from lib.db import init_db; init_db()
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "list-customers"
|
||||
if cmd == "list-customers": print(json.dumps(list_customers(), indent=2))
|
||||
elif cmd == "pull-jobs": print(json.dumps(pull_jobs(), indent=2))
|
||||
elif cmd == "push-estimate": print(json.dumps(push_estimate(sys.argv[2])))
|
||||
elif cmd == "push-invoice": print(json.dumps(push_invoice(sys.argv[2])))
|
||||
elif cmd == "sync-status": print(json.dumps(get_sync_status(sys.argv[2]), indent=2))
|
||||
else: print(json.dumps({"error": f"unknown: {cmd}"}))
|
||||
35
scripts/material_list.py
Normal file
35
scripts/material_list.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Material list generator from estimates."""
|
||||
import json, sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from lib.db import get_db, row_to_dict, rows_to_list
|
||||
|
||||
def generate(estimate_id):
|
||||
conn = get_db()
|
||||
est = row_to_dict(conn.execute("SELECT * FROM estimates WHERE id=?", (estimate_id,)).fetchone())
|
||||
if not est: conn.close(); return {"error": "estimate not found"}
|
||||
items = rows_to_list(conn.execute("SELECT * FROM line_items WHERE estimate_id=? AND category='materials'", (estimate_id,)).fetchall())
|
||||
job = row_to_dict(conn.execute("SELECT * FROM jobs WHERE id=?", (est["job_id"],)).fetchone())
|
||||
conn.close()
|
||||
|
||||
materials = []
|
||||
total = 0
|
||||
for item in items:
|
||||
materials.append({
|
||||
"item": item["description"],
|
||||
"quantity": item["quantity"],
|
||||
"unit": item["unit"],
|
||||
"est_cost": round(item["total"], 2)
|
||||
})
|
||||
total += item["total"]
|
||||
|
||||
return {
|
||||
"job": job.get("description", "") if job else "",
|
||||
"materials": materials,
|
||||
"total_materials_cost": round(total, 2),
|
||||
"items_count": len(materials)
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
from lib.db import init_db; init_db()
|
||||
if len(sys.argv) < 2: print(json.dumps({"error": "usage: material_list.py <estimate_id>"}))
|
||||
else: print(json.dumps(generate(sys.argv[1]), indent=2))
|
||||
71
scripts/pricing_catalog.py
Normal file
71
scripts/pricing_catalog.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Pricing catalog CRUD operations."""
|
||||
import json, sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from lib.db import get_db, new_id, now, row_to_dict, rows_to_list
|
||||
|
||||
def list_catalogs():
|
||||
conn = get_db()
|
||||
rows = conn.execute("SELECT * FROM pricing_catalogs ORDER BY created_at DESC").fetchall()
|
||||
conn.close()
|
||||
result = rows_to_list(rows)
|
||||
for r in result:
|
||||
r["labor_rates"] = json.loads(r["labor_rates"]) if r["labor_rates"] else {}
|
||||
r["material_markups"] = json.loads(r["material_markups"]) if r["material_markups"] else {}
|
||||
return {"catalogs": result}
|
||||
|
||||
def create_catalog(name, labor_rates, material_markups=None):
|
||||
cid = new_id()
|
||||
conn = get_db()
|
||||
conn.execute("INSERT INTO pricing_catalogs (id, name, labor_rates, material_markups, created_at) VALUES (?,?,?,?,?)",
|
||||
(cid, name, json.dumps(labor_rates), json.dumps(material_markups or {}), now()))
|
||||
conn.commit(); conn.close()
|
||||
return {"id": cid, "name": name}
|
||||
|
||||
def get_catalog(catalog_id):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT * FROM pricing_catalogs WHERE id=?", (catalog_id,)).fetchone()
|
||||
conn.close()
|
||||
if not row: return {"error": "not found"}
|
||||
r = row_to_dict(row)
|
||||
r["labor_rates"] = json.loads(r["labor_rates"]) if r["labor_rates"] else {}
|
||||
r["material_markups"] = json.loads(r["material_markups"]) if r["material_markups"] else {}
|
||||
return r
|
||||
|
||||
def update_catalog(catalog_id, name=None, labor_rates=None, material_markups=None):
|
||||
conn = get_db()
|
||||
if name: conn.execute("UPDATE pricing_catalogs SET name=? WHERE id=?", (name, catalog_id))
|
||||
if labor_rates: conn.execute("UPDATE pricing_catalogs SET labor_rates=? WHERE id=?", (json.dumps(labor_rates), catalog_id))
|
||||
if material_markups: conn.execute("UPDATE pricing_catalogs SET material_markups=? WHERE id=?", (json.dumps(material_markups), catalog_id))
|
||||
conn.commit(); conn.close()
|
||||
return {"ok": True, "id": catalog_id}
|
||||
|
||||
def delete_catalog(catalog_id):
|
||||
conn = get_db()
|
||||
conn.execute("DELETE FROM pricing_catalogs WHERE id=?", (catalog_id,))
|
||||
conn.commit(); conn.close()
|
||||
return {"ok": True, "deleted": catalog_id}
|
||||
|
||||
def seed_default():
|
||||
"""Seed default Kellow Construction pricing catalog."""
|
||||
existing = list_catalogs()
|
||||
if existing["catalogs"]: return {"ok": True, "msg": "already seeded"}
|
||||
return create_catalog("Kellow Default", {
|
||||
"general_labor": 55, "framing": 85, "electrical": 95,
|
||||
"plumbing": 90, "painting": 65, "drywall": 75,
|
||||
"tile": 80, "flooring": 70, "roofing": 85,
|
||||
"concrete": 75, "demolition": 60, "finish_carpentry": 90,
|
||||
"project_management": 95, "design": 110
|
||||
}, {"lumber": 1.15, "drywall": 1.20, "tile": 1.25,
|
||||
"plumbing_fixtures": 1.20, "electrical_fixtures": 1.15,
|
||||
"paint": 1.10, "hardware": 1.15, "appliances": 1.10})
|
||||
|
||||
if __name__ == "__main__":
|
||||
from lib.db import init_db; init_db()
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "list"
|
||||
if cmd == "list": print(json.dumps(list_catalogs(), indent=2))
|
||||
elif cmd == "create": print(json.dumps(create_catalog(sys.argv[2], json.loads(sys.argv[3]), json.loads(sys.argv[4]) if len(sys.argv) > 4 else None)))
|
||||
elif cmd == "get": print(json.dumps(get_catalog(sys.argv[2]), indent=2))
|
||||
elif cmd == "update": print(json.dumps(update_catalog(sys.argv[2], labor_rates=json.loads(sys.argv[3]) if len(sys.argv) > 3 else None)))
|
||||
elif cmd == "delete": print(json.dumps(delete_catalog(sys.argv[2])))
|
||||
elif cmd == "seed": print(json.dumps(seed_default()))
|
||||
else: print(json.dumps({"error": f"unknown command: {cmd}"}))
|
||||
102
scripts/project_mgmt.py
Normal file
102
scripts/project_mgmt.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Project management: jobs and estimates CRUD."""
|
||||
import json, sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from lib.db import get_db, new_id, now, row_to_dict, rows_to_list
|
||||
|
||||
def create_job(client_name, address=None, description=None, jobtread_job_id=None, jobtread_customer_id=None):
|
||||
jid = new_id()
|
||||
conn = get_db()
|
||||
conn.execute("""INSERT INTO jobs (id, client_name, address, description, jobtread_job_id, jobtread_customer_id, status, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
(jid, client_name, address, description, jobtread_job_id, jobtread_customer_id, "proposal", now(), now()))
|
||||
conn.commit(); conn.close()
|
||||
return {"id": jid, "client_name": client_name, "status": "proposal"}
|
||||
|
||||
def list_jobs(status=None):
|
||||
conn = get_db()
|
||||
if status:
|
||||
rows = conn.execute("SELECT * FROM jobs WHERE status=? ORDER BY created_at DESC", (status,)).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM jobs ORDER BY created_at DESC").fetchall()
|
||||
conn.close()
|
||||
return {"jobs": rows_to_list(rows)}
|
||||
|
||||
def get_job(job_id):
|
||||
conn = get_db()
|
||||
job = row_to_dict(conn.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone())
|
||||
if not job: conn.close(); return {"error": "job not found"}
|
||||
job["estimates"] = rows_to_list(conn.execute("SELECT * FROM estimates WHERE job_id=?", (job_id,)).fetchall())
|
||||
job["invoices"] = rows_to_list(conn.execute("SELECT * FROM invoices WHERE job_id=?", (job_id,)).fetchall())
|
||||
conn.close()
|
||||
return job
|
||||
|
||||
def update_job(job_id, status=None, client_name=None, address=None, description=None):
|
||||
conn = get_db()
|
||||
updates = []
|
||||
params = []
|
||||
for field, val in [("status", status), ("client_name", client_name), ("address", address), ("description", description)]:
|
||||
if val is not None:
|
||||
updates.append(f"{field}=?"); params.append(val)
|
||||
if updates:
|
||||
updates.append("updated_at=?"); params.append(now())
|
||||
params.append(job_id)
|
||||
conn.execute(f"UPDATE jobs SET {','.join(updates)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"ok": True, "id": job_id}
|
||||
|
||||
def create_estimate(job_id, line_items, labor_hours=None, labor_rate=None, markup_percent=20):
|
||||
eid = new_id()
|
||||
conn = get_db()
|
||||
materials_cost = 0
|
||||
total_labor_hours = labor_hours or 0
|
||||
# Calculate totals first
|
||||
item_rows = []
|
||||
for item in line_items:
|
||||
lid = new_id()
|
||||
total = item.get("quantity", 1) * item.get("unit_cost", 0)
|
||||
item_rows.append((lid, eid, item.get("description",""), item.get("category",""), item.get("quantity",1), item.get("unit","ea"), item.get("unit_cost",0), total))
|
||||
if item.get("category") == "labor":
|
||||
total_labor_hours += item.get("quantity", 0)
|
||||
else:
|
||||
materials_cost += total
|
||||
effective_rate = labor_rate or 85
|
||||
labor_cost = total_labor_hours * effective_rate
|
||||
subtotal = labor_cost + materials_cost
|
||||
markup = subtotal * (markup_percent / 100)
|
||||
total_cost = subtotal + markup
|
||||
# Insert estimate first, then line items
|
||||
conn.execute("""INSERT INTO estimates (id, job_id, labor_hours, labor_rate, materials_cost, markup_percent, total_cost, status, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
(eid, job_id, total_labor_hours, effective_rate, materials_cost, markup_percent, total_cost, "draft", now()))
|
||||
for row in item_rows:
|
||||
conn.execute("INSERT INTO line_items (id, estimate_id, description, category, quantity, unit, unit_cost, total) VALUES (?,?,?,?,?,?,?,?)", row)
|
||||
conn.commit(); conn.close()
|
||||
return {"id": eid, "job_id": job_id, "labor_hours": total_labor_hours, "labor_rate": effective_rate,
|
||||
"materials_cost": materials_cost, "markup_percent": markup_percent, "total_cost": total_cost}
|
||||
|
||||
def get_estimate(estimate_id):
|
||||
conn = get_db()
|
||||
est = row_to_dict(conn.execute("SELECT * FROM estimates WHERE id=?", (estimate_id,)).fetchone())
|
||||
if not est: conn.close(); return {"error": "estimate not found"}
|
||||
est["line_items"] = rows_to_list(conn.execute("SELECT * FROM line_items WHERE estimate_id=?", (estimate_id,)).fetchall())
|
||||
job = row_to_dict(conn.execute("SELECT * FROM jobs WHERE id=?", (est["job_id"],)).fetchone())
|
||||
est["job"] = job
|
||||
conn.close()
|
||||
return est
|
||||
|
||||
def update_estimate_status(estimate_id, status):
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE estimates SET status=? WHERE id=?", (status, estimate_id))
|
||||
conn.commit(); conn.close()
|
||||
return {"ok": True, "id": estimate_id, "status": status}
|
||||
|
||||
if __name__ == "__main__":
|
||||
from lib.db import init_db; init_db()
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "list-jobs"
|
||||
if cmd == "create-job": print(json.dumps(create_job(sys.argv[2], sys.argv[3] if len(sys.argv)>3 else None, sys.argv[4] if len(sys.argv)>4 else None)))
|
||||
elif cmd == "list-jobs": print(json.dumps(list_jobs(sys.argv[2] if len(sys.argv)>2 else None), indent=2))
|
||||
elif cmd == "get-job": print(json.dumps(get_job(sys.argv[2]), indent=2))
|
||||
elif cmd == "get-estimate": print(json.dumps(get_estimate(sys.argv[2]), indent=2))
|
||||
elif cmd == "create-estimate": print(json.dumps(create_estimate(sys.argv[2], json.loads(sys.argv[3]), markup_percent=float(sys.argv[4]) if len(sys.argv)>4 else 20)))
|
||||
else: print(json.dumps({"error": f"unknown: {cmd}"}))
|
||||
35
scripts/project_scheduler.py
Normal file
35
scripts/project_scheduler.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Project scheduler: milestones and timeline management."""
|
||||
import json, sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from lib.db import get_db, new_id, row_to_dict, rows_to_list
|
||||
|
||||
def set_milestone(job_id, phase_name, start_date=None, end_date=None, status="pending"):
|
||||
mid = new_id()
|
||||
conn = get_db()
|
||||
conn.execute("INSERT INTO project_milestones (id, job_id, phase_name, start_date, end_date, status) VALUES (?,?,?,?,?,?)",
|
||||
(mid, job_id, phase_name, start_date, end_date, status))
|
||||
conn.commit(); conn.close()
|
||||
return {"id": mid, "job_id": job_id, "phase": phase_name}
|
||||
|
||||
def update_milestone(milestone_id, status=None, start_date=None, end_date=None):
|
||||
conn = get_db()
|
||||
if status: conn.execute("UPDATE project_milestones SET status=? WHERE id=?", (status, milestone_id))
|
||||
if start_date: conn.execute("UPDATE project_milestones SET start_date=? WHERE id=?", (start_date, milestone_id))
|
||||
if end_date: conn.execute("UPDATE project_milestones SET end_date=? WHERE id=?", (end_date, milestone_id))
|
||||
conn.commit(); conn.close()
|
||||
return {"ok": True, "id": milestone_id}
|
||||
|
||||
def get_schedule(job_id):
|
||||
conn = get_db()
|
||||
job = row_to_dict(conn.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone())
|
||||
milestones = rows_to_list(conn.execute("SELECT * FROM project_milestones WHERE job_id=? ORDER BY start_date", (job_id,)).fetchall())
|
||||
conn.close()
|
||||
if not job: return {"error": "job not found"}
|
||||
return {"job_id": job_id, "client": job.get("client_name"), "milestones": milestones}
|
||||
|
||||
if __name__ == "__main__":
|
||||
from lib.db import init_db; init_db()
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "get"
|
||||
if cmd == "set": print(json.dumps(set_milestone(sys.argv[2], sys.argv[3], sys.argv[4] if len(sys.argv)>4 else None, sys.argv[5] if len(sys.argv)>5 else None)))
|
||||
elif cmd == "get": print(json.dumps(get_schedule(sys.argv[2]), indent=2))
|
||||
else: print(json.dumps({"error": f"unknown: {cmd}"}))
|
||||
104
scripts/proposal_drafter.py
Normal file
104
scripts/proposal_drafter.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Proposal drafter: generate professional proposals from estimates."""
|
||||
import json, sys, os
|
||||
from datetime import datetime, timedelta
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from lib.db import get_db, row_to_dict, rows_to_list
|
||||
|
||||
TEMPLATE = """# Proposal — {client_name}
|
||||
## {job_description}
|
||||
|
||||
**Prepared by:** Kellow Construction
|
||||
**Date:** {date}
|
||||
**Valid for:** 30 days
|
||||
|
||||
---
|
||||
|
||||
## Scope of Work
|
||||
|
||||
{scope_items}
|
||||
|
||||
## Investment Summary
|
||||
|
||||
| Category | Amount |
|
||||
|----------|--------|
|
||||
| Labor ({labor_hours} hrs @ ${labor_rate}/hr) | ${labor_cost:,.2f} |
|
||||
| Materials | ${materials_cost:,.2f} |
|
||||
| Subtotal | ${subtotal:,.2f} |
|
||||
| Overhead & Profit ({markup_percent}%) | ${markup:,.2f} |
|
||||
| **Total** | **${total_cost:,.2f}** |
|
||||
|
||||
## Timeline
|
||||
|
||||
Estimated duration: {timeline}
|
||||
Projected start: Upon approval and permitting
|
||||
|
||||
## Payment Terms
|
||||
|
||||
- 30% deposit upon contract signing
|
||||
- Progress payments at milestones
|
||||
- Final 10% upon completion and walkthrough
|
||||
|
||||
## Exclusions
|
||||
|
||||
- Permits and fees (handled separately)
|
||||
- Unforeseen structural issues
|
||||
- Owner-supplied materials
|
||||
- Landscaping and exterior work (unless specified)
|
||||
|
||||
## Warranty
|
||||
|
||||
Kellow Construction provides a 1-year workmanship warranty on all labor performed.
|
||||
|
||||
---
|
||||
|
||||
*This proposal is valid for 30 days from the date above. Acceptance constitutes agreement to the terms outlined herein.*
|
||||
"""
|
||||
|
||||
def draft(estimate_id):
|
||||
conn = get_db()
|
||||
est = row_to_dict(conn.execute("SELECT * FROM estimates WHERE id=?", (estimate_id,)).fetchone())
|
||||
if not est: conn.close(); return {"error": "estimate not found"}
|
||||
items = rows_to_list(conn.execute("SELECT * FROM line_items WHERE estimate_id=?", (estimate_id,)).fetchall())
|
||||
job = row_to_dict(conn.execute("SELECT * FROM jobs WHERE id=?", (est["job_id"],)).fetchone())
|
||||
conn.close()
|
||||
|
||||
labor_cost = est["labor_hours"] * est["labor_rate"]
|
||||
subtotal = labor_cost + est["materials_cost"]
|
||||
markup = subtotal * (est["markup_percent"] / 100)
|
||||
|
||||
# Build scope items
|
||||
scope_lines = []
|
||||
for item in items:
|
||||
scope_lines.append(f"- {item['description']}: {item['quantity']} {item['unit']} @ ${item['unit_cost']:.2f}/{item['unit']}")
|
||||
|
||||
# Estimate timeline from labor hours
|
||||
weeks = max(1, int(est["labor_hours"] / 40))
|
||||
timeline = f"{weeks} week{'s' if weeks > 1 else ''}"
|
||||
|
||||
proposal = TEMPLATE.format(
|
||||
client_name=job.get("client_name", "Client") if job else "Client",
|
||||
job_description=job.get("description", "Project") if job else "Project",
|
||||
date=datetime.now().strftime("%B %d, %Y"),
|
||||
scope_items="\n".join(scope_lines) if scope_lines else "- As discussed",
|
||||
labor_hours=round(est["labor_hours"], 1),
|
||||
labor_rate=est["labor_rate"],
|
||||
labor_cost=labor_cost,
|
||||
materials_cost=est["materials_cost"],
|
||||
subtotal=subtotal,
|
||||
markup_percent=est["markup_percent"],
|
||||
markup=markup,
|
||||
total_cost=est["total_cost"],
|
||||
timeline=timeline,
|
||||
)
|
||||
return {"proposal": proposal, "estimate_id": estimate_id, "total": est["total_cost"]}
|
||||
|
||||
if __name__ == "__main__":
|
||||
from lib.db import init_db; init_db()
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({"error": "usage: proposal_drafter.py <estimate_id>"}))
|
||||
else:
|
||||
result = draft(sys.argv[1])
|
||||
if "proposal" in result:
|
||||
print(result["proposal"])
|
||||
else:
|
||||
print(json.dumps(result))
|
||||
50
scripts/scope_generator.py
Normal file
50
scripts/scope_generator.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Scope of work generator from estimates."""
|
||||
import json, sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from lib.db import get_db, row_to_dict, rows_to_list
|
||||
|
||||
STANDARD_CLAUSES = """
|
||||
## General Conditions
|
||||
|
||||
- All work performed in accordance with local building codes and regulations
|
||||
- Contractor to obtain all necessary permits unless otherwise noted
|
||||
- Work area to be kept clean and debris removed daily
|
||||
- Final cleanup included upon project completion
|
||||
|
||||
## Warranty
|
||||
|
||||
- 1-year workmanship warranty on all labor
|
||||
- Manufacturer warranties apply to all materials and fixtures
|
||||
|
||||
## Exclusions
|
||||
|
||||
- Unforeseen conditions (mold, asbestos, structural damage) — addressed via change order
|
||||
- Owner-supplied materials — contractor not responsible for defects
|
||||
- Landscaping, exterior painting, or work not explicitly listed above
|
||||
- Furniture moving or storage
|
||||
"""
|
||||
|
||||
def generate(estimate_id):
|
||||
conn = get_db()
|
||||
est = row_to_dict(conn.execute("SELECT * FROM estimates WHERE id=?", (estimate_id,)).fetchone())
|
||||
if not est: conn.close(); return {"error": "estimate not found"}
|
||||
items = rows_to_list(conn.execute("SELECT * FROM line_items WHERE estimate_id=?", (estimate_id,)).fetchall())
|
||||
job = row_to_dict(conn.execute("SELECT * FROM jobs WHERE id=?", (est["job_id"],)).fetchone())
|
||||
conn.close()
|
||||
|
||||
lines = [f"# Scope of Work — {job.get('client_name','Client') if job else 'Client'}",
|
||||
f"**Project:** {job.get('description','') if job else ''}",
|
||||
f"**Address:** {job.get('address','') if job else ''}", "", "## Work Items", ""]
|
||||
for i, item in enumerate(items, 1):
|
||||
if item["category"] == "labor":
|
||||
lines.append(f"{i}. **{item['description']}** — {item['quantity']} {item['unit']}")
|
||||
lines.append(STANDARD_CLAUSES)
|
||||
scope = "\n".join(lines)
|
||||
return {"scope": scope, "estimate_id": estimate_id, "items_count": len(items)}
|
||||
|
||||
if __name__ == "__main__":
|
||||
from lib.db import init_db; init_db()
|
||||
if len(sys.argv) < 2: print(json.dumps({"error": "usage: scope_generator.py <estimate_id>"}))
|
||||
else:
|
||||
r = generate(sys.argv[1])
|
||||
print(r.get("scope", json.dumps(r)))
|
||||
35
scripts/signature_mgmt.py
Normal file
35
scripts/signature_mgmt.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Signature management: request and record client signatures."""
|
||||
import json, sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from lib.db import get_db, new_id, now, row_to_dict, rows_to_list
|
||||
|
||||
def request_signature(estimate_id, client_name):
|
||||
sid = new_id()
|
||||
conn = get_db()
|
||||
conn.execute("INSERT INTO signatures (id, estimate_id, client_name) VALUES (?,?,?)",
|
||||
(sid, estimate_id, client_name))
|
||||
conn.commit(); conn.close()
|
||||
return {"signature_id": sid, "estimate_id": estimate_id, "client_name": client_name, "status": "pending"}
|
||||
|
||||
def record_signature(signature_id, signature_data="approved_via_telegram"):
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE signatures SET signature_data=?, signed_at=? WHERE id=?",
|
||||
(signature_data, now(), signature_id))
|
||||
conn.commit(); conn.close()
|
||||
# Also log as activity
|
||||
sig = row_to_dict(conn.execute("SELECT * FROM signatures WHERE id=?", (signature_id,)).fetchone()) if False else None
|
||||
return {"ok": True, "signature_id": signature_id, "signed_at": now()}
|
||||
|
||||
def get_signature(estimate_id):
|
||||
conn = get_db()
|
||||
row = row_to_dict(conn.execute("SELECT * FROM signatures WHERE estimate_id=? ORDER BY signed_at DESC LIMIT 1", (estimate_id,)).fetchone())
|
||||
conn.close()
|
||||
return row if row else {"status": "no signature found"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
from lib.db import init_db; init_db()
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "get"
|
||||
if cmd == "request": print(json.dumps(request_signature(sys.argv[2], sys.argv[3])))
|
||||
elif cmd == "record": print(json.dumps(record_signature(sys.argv[2], sys.argv[3] if len(sys.argv)>3 else "approved")))
|
||||
elif cmd == "get": print(json.dumps(get_signature(sys.argv[2]), indent=2))
|
||||
else: print(json.dumps({"error": f"unknown: {cmd}"}))
|
||||
44
scripts/site_analyzer.py
Normal file
44
scripts/site_analyzer.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Site walkthrough analyzer: structured report from observations."""
|
||||
import json, sys, os, re
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from lib.db import now
|
||||
|
||||
# Issue patterns to flag
|
||||
ISSUE_PATTERNS = {
|
||||
r"water\s*damage|moisture|leak|mold": {"severity": "high", "category": "water_damage", "cost_impact": 1200},
|
||||
r"crack|structural|foundation\s*issue|settling": {"severity": "high", "category": "structural", "cost_impact": 3000},
|
||||
r"rot|decay|termite|pest": {"severity": "high", "category": "pest_damage", "cost_impact": 2000},
|
||||
r"asbestos|lead\s*paint|hazmat": {"severity": "critical", "category": "hazmat", "cost_impact": 5000},
|
||||
r"outdated\s*wiring|knob.and.tube|electrical\s*issue": {"severity": "medium", "category": "electrical", "cost_impact": 2500},
|
||||
r"rust|corrosion|pipe": {"severity": "medium", "category": "plumbing", "cost_impact": 1500},
|
||||
}
|
||||
|
||||
def analyze(observations, job_id=None, photos_description=None):
|
||||
"""Analyze site observations and flag issues."""
|
||||
combined = f"{observations} {photos_description or ''}"
|
||||
issues = []
|
||||
total_impact = 0
|
||||
for pattern, info in ISSUE_PATTERNS.items():
|
||||
if re.search(pattern, combined, re.I):
|
||||
issues.append({
|
||||
"category": info["category"],
|
||||
"severity": info["severity"],
|
||||
"estimated_cost_impact": info["cost_impact"],
|
||||
"matched": re.search(pattern, combined, re.I).group(0)
|
||||
})
|
||||
total_impact += info["cost_impact"]
|
||||
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"analyzed_at": now(),
|
||||
"observations": observations,
|
||||
"photos_description": photos_description,
|
||||
"issues_found": len(issues),
|
||||
"issues": issues,
|
||||
"total_cost_impact": total_impact,
|
||||
"recommendation": f"Add ${total_impact:,.0f} to estimate for remediation" if issues else "No significant issues detected"
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2: print(json.dumps({"error": "usage: site_analyzer.py <observations> [photos_desc]"}))
|
||||
else: print(json.dumps(analyze(sys.argv[1], photos_description=sys.argv[2] if len(sys.argv)>2 else None), indent=2))
|
||||
Reference in New Issue
Block a user