Initial commit: Handoff Pro MCP server for Kellow Construction
This commit is contained in:
0
lib/__init__.py
Normal file
0
lib/__init__.py
Normal file
156
lib/db.py
Normal file
156
lib/db.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""SQLite database initialization and access for Handoff Pro."""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
DB_PATH = os.environ.get("HANDOFF_DB",
|
||||
os.path.join(os.path.dirname(__file__), "..", "data", "handoff.db"))
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT DEFAULT 'kellow',
|
||||
jobtread_job_id TEXT UNIQUE,
|
||||
client_name TEXT,
|
||||
jobtread_customer_id TEXT,
|
||||
address TEXT,
|
||||
description TEXT,
|
||||
status TEXT DEFAULT 'proposal',
|
||||
created_at TEXT,
|
||||
updated_at TEXT,
|
||||
synced_to_jobtread_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS estimates (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT REFERENCES jobs(id),
|
||||
jobtread_estimate_id TEXT UNIQUE,
|
||||
labor_hours REAL DEFAULT 0,
|
||||
labor_rate REAL DEFAULT 0,
|
||||
materials_cost REAL DEFAULT 0,
|
||||
markup_percent REAL DEFAULT 20,
|
||||
total_cost REAL DEFAULT 0,
|
||||
notes TEXT,
|
||||
status TEXT DEFAULT 'draft',
|
||||
created_at TEXT,
|
||||
synced_to_jobtread_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS line_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
estimate_id TEXT REFERENCES estimates(id),
|
||||
description TEXT,
|
||||
category TEXT,
|
||||
quantity REAL DEFAULT 1,
|
||||
unit TEXT DEFAULT 'ea',
|
||||
unit_cost REAL DEFAULT 0,
|
||||
total REAL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pricing_catalogs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT DEFAULT 'kellow',
|
||||
name TEXT,
|
||||
labor_rates TEXT,
|
||||
material_markups TEXT,
|
||||
created_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id TEXT PRIMARY KEY,
|
||||
estimate_id TEXT REFERENCES estimates(id),
|
||||
job_id TEXT REFERENCES jobs(id),
|
||||
invoice_number TEXT,
|
||||
jobtread_invoice_id TEXT UNIQUE,
|
||||
qbo_invoice_id TEXT,
|
||||
amount_due REAL DEFAULT 0,
|
||||
amount_paid REAL DEFAULT 0,
|
||||
due_date TEXT,
|
||||
status TEXT DEFAULT 'draft',
|
||||
created_at TEXT,
|
||||
synced_to_jobtread_at TEXT,
|
||||
synced_to_qbo_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT REFERENCES jobs(id),
|
||||
log_date TEXT,
|
||||
crew_names TEXT,
|
||||
hours_worked REAL DEFAULT 0,
|
||||
notes TEXT,
|
||||
photos TEXT,
|
||||
created_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS signatures (
|
||||
id TEXT PRIMARY KEY,
|
||||
estimate_id TEXT REFERENCES estimates(id),
|
||||
client_name TEXT,
|
||||
signature_data TEXT,
|
||||
signed_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client_activity (
|
||||
id TEXT PRIMARY KEY,
|
||||
estimate_id TEXT REFERENCES estimates(id),
|
||||
job_id TEXT REFERENCES jobs(id),
|
||||
action TEXT,
|
||||
metadata TEXT,
|
||||
timestamp TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobtread_sync_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
local_id TEXT,
|
||||
jobtread_id TEXT,
|
||||
entity_type TEXT,
|
||||
action TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
error_message TEXT,
|
||||
synced_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_milestones (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT REFERENCES jobs(id),
|
||||
phase_name TEXT,
|
||||
start_date TEXT,
|
||||
end_date TEXT,
|
||||
status TEXT DEFAULT 'pending'
|
||||
);
|
||||
"""
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
return conn
|
||||
|
||||
def init_db():
|
||||
conn = get_db()
|
||||
conn.executescript(SCHEMA)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def new_id():
|
||||
return str(uuid.uuid4())[:8]
|
||||
|
||||
def now():
|
||||
return datetime.now().astimezone().isoformat()
|
||||
|
||||
def row_to_dict(row):
|
||||
if row is None:
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
def rows_to_list(rows):
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
print(json.dumps({"ok": True, "db_path": DB_PATH}))
|
||||
41
lib/pave.py
Normal file
41
lib/pave.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""JobTread Pave API helper. Reads creds from .jobtread-api.json."""
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
PAVE_URL = "https://api.jobtread.com/pave"
|
||||
_creds = None
|
||||
|
||||
def _get_creds():
|
||||
global _creds
|
||||
if _creds:
|
||||
return _creds
|
||||
paths = [
|
||||
os.environ.get("JOBTREAD_CREDS"),
|
||||
os.path.join(os.path.dirname(__file__), "..", "data", ".jobtread-api.json"),
|
||||
"/home/node/.openclaw/workspace/agents/tom/.jobtread-api.json",
|
||||
]
|
||||
for p in paths:
|
||||
if p and os.path.exists(p):
|
||||
with open(p) as f:
|
||||
_creds = json.load(f)
|
||||
return _creds
|
||||
raise RuntimeError("No .jobtread-api.json found")
|
||||
|
||||
def get_grant_key():
|
||||
return _get_creds()["grantKey"]
|
||||
|
||||
def get_org_id():
|
||||
return _get_creds()["organizationId"]
|
||||
|
||||
def pave_query(query: dict) -> dict:
|
||||
"""Execute a Pave API query. Injects grantKey automatically."""
|
||||
payload = {"query": {"$": {"grantKey": get_grant_key()}, **query}}
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(PAVE_URL, data=data,
|
||||
headers={"Content-Type": "application/json"}, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
result = json.loads(resp.read())
|
||||
if "errors" in result:
|
||||
raise RuntimeError(f"Pave API error: {result['errors']}")
|
||||
return result
|
||||
54
lib/qbo.py
Normal file
54
lib/qbo.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""QuickBooks Online API helper. Tokens from http://192.168.86.11:18801."""
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
TOKEN_URL = "http://192.168.86.11:18801/kellow-tokens.json"
|
||||
REFRESH_URL = "http://192.168.86.11:18803/qbo/refresh?tenant=kellow"
|
||||
QBO_BASE = "https://quickbooks.api.intuit.com/v3/company"
|
||||
|
||||
def get_token():
|
||||
"""Fetch valid QBO access token + realm_id, auto-refresh if expired."""
|
||||
with urllib.request.urlopen(TOKEN_URL, timeout=10) as resp:
|
||||
tokens = json.loads(resp.read())
|
||||
if time.time() >= tokens.get("expires_at_epoch", 0):
|
||||
req = urllib.request.Request(REFRESH_URL, method="POST")
|
||||
urllib.request.urlopen(req, timeout=15)
|
||||
with urllib.request.urlopen(TOKEN_URL, timeout=10) as resp:
|
||||
tokens = json.loads(resp.read())
|
||||
return tokens["access_token"], tokens["realm_id"]
|
||||
|
||||
def qbo_request(method, endpoint, body=None):
|
||||
"""Make authenticated QBO API request. Returns parsed JSON."""
|
||||
access_token, realm_id = get_token()
|
||||
url = f"{QBO_BASE}/{realm_id}/{endpoint}"
|
||||
data = json.dumps(body).encode() if body else None
|
||||
req = urllib.request.Request(url, data=data, method=method, headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
def create_invoice(customer_id, line_items, due_date=None):
|
||||
"""Create invoice in QBO. line_items: [{description, amount, qty}]"""
|
||||
lines = []
|
||||
for i, item in enumerate(line_items, 1):
|
||||
lines.append({
|
||||
"LineNum": i,
|
||||
"Amount": item["amount"],
|
||||
"DetailType": "SalesItemLineDetail",
|
||||
"Description": item.get("description", ""),
|
||||
"SalesItemLineDetail": {
|
||||
"Qty": item.get("qty", 1),
|
||||
"UnitPrice": item["amount"] / item.get("qty", 1),
|
||||
}
|
||||
})
|
||||
invoice = {
|
||||
"CustomerRef": {"value": customer_id},
|
||||
"Line": lines,
|
||||
}
|
||||
if due_date:
|
||||
invoice["DueDate"] = due_date
|
||||
return qbo_request("POST", "invoice", invoice)
|
||||
Reference in New Issue
Block a user